`` parent in the whole document::
-
- >>> xp("(//ul/li)[1]")
- [u'1 ']
-
-When querying by class, consider using CSS
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-Because an element can contain multiple CSS classes, the XPath way to select elements
-by class is the rather verbose::
-
- *[contains(concat(' ', normalize-space(@class), ' '), ' someclass ')]
-
-If you use ``@class='someclass'`` you may end up missing elements that have
-other classes, and if you just use ``contains(@class, 'someclass')`` to make up
-for that you may end up with more elements that you want, if they have a different
-class name that shares the string ``someclass``.
-
-As it turns out, Scrapy selectors allow you to chain selectors, so most of the time
-you can just select by class using CSS and then switch to XPath when needed::
-
- >>> from scrapy import Selector
- >>> sel = Selector(text='Special date
')
- >>> sel.css('.shout').xpath('./time/@datetime').extract()
- [u'2014-07-23 19:00']
-
-This is cleaner than using the verbose XPath trick shown above. Just remember
-to use the ``.`` in the XPath expressions that will follow.
+.. autofunction:: parsel.xpathfuncs.set_xpathfunc
.. _topics-selectors-ref:
@@ -596,132 +907,79 @@ Built-in Selectors reference
Selector objects
----------------
-.. class:: Selector(response=None, text=None, type=None)
+.. autoclass:: Selector
- An instance of :class:`Selector` is a wrapper over response to select
- certain parts of its content.
-
- ``response`` is an :class:`~scrapy.http.HtmlResponse` or an
- :class:`~scrapy.http.XmlResponse` object that will be used for selecting and
- extracting data.
-
- ``text`` is a unicode string or utf-8 encoded text for cases when a
- ``response`` isn't available. Using ``text`` and ``response`` together is
- undefined behavior.
-
- ``type`` defines the selector type, it can be ``"html"``, ``"xml"`` or ``None`` (default).
-
- If ``type`` is ``None``, the selector automatically chooses the best type
- based on ``response`` type (see below), or defaults to ``"html"`` in case it
- is used together with ``text``.
-
- If ``type`` is ``None`` and a ``response`` is passed, the selector type is
- inferred from the response type as follows:
-
- * ``"html"`` for :class:`~scrapy.http.HtmlResponse` type
- * ``"xml"`` for :class:`~scrapy.http.XmlResponse` type
- * ``"html"`` for anything else
-
- Otherwise, if ``type`` is set, the selector type will be forced and no
- detection will occur.
-
- .. method:: xpath(query)
-
- Find nodes matching the xpath ``query`` and return the result as a
- :class:`SelectorList` instance with all elements flattened. List
- elements implement :class:`Selector` interface too.
-
- ``query`` is a string containing the XPATH query to apply.
+ .. automethod:: xpath
.. note::
For convenience, this method can be called as ``response.xpath()``
- .. method:: css(query)
-
- Apply the given CSS selector and return a :class:`SelectorList` instance.
-
- ``query`` is a string containing the CSS selector to apply.
-
- In the background, CSS queries are translated into XPath queries using
- `cssselect`_ library and run ``.xpath()`` method.
+ .. automethod:: css
.. note::
- For convenience this method can be called as ``response.css()``
+ For convenience, this method can be called as ``response.css()``
- .. method:: extract()
+ .. automethod:: get
- Serialize and return the matched nodes as a list of unicode strings.
- Percent encoded content is unquoted.
+ See also: :ref:`old-extraction-api`
- .. method:: re(regex)
+ .. autoattribute:: attrib
- Apply the given regex and return a list of unicode strings with the
- matches.
+ See also: :ref:`selecting-attributes`.
- ``regex`` can be either a compiled regular expression or a string which
- will be compiled to a regular expression using ``re.compile(regex)``
+ .. automethod:: re
- .. note::
+ .. automethod:: re_first
- Note that ``re()`` and ``re_first()`` both decode HTML entities (except ``<`` and ``&``).
+ .. automethod:: register_namespace
- .. method:: register_namespace(prefix, uri)
+ .. automethod:: remove_namespaces
- Register the given namespace to be used in this :class:`Selector`.
- Without registering namespaces you can't select or extract data from
- non-standard namespaces. See examples below.
+ .. automethod:: __bool__
- .. method:: remove_namespaces()
-
- Remove all namespaces, allowing to traverse the document using
- namespace-less xpaths. See example below.
-
- .. method:: __nonzero__()
-
- Returns ``True`` if there is any real content selected or ``False``
- otherwise. In other words, the boolean value of a :class:`Selector` is
- given by the contents it selects.
+ .. automethod:: getall
+ This method is added to Selector for consistency; it is more useful
+ with SelectorList. See also: :ref:`old-extraction-api`
SelectorList objects
--------------------
-.. class:: SelectorList
+.. autoclass:: SelectorList
- The :class:`SelectorList` class is a subclass of the builtin ``list``
- class, which provides a few additional methods.
+ .. automethod:: xpath
- .. method:: xpath(query)
+ .. automethod:: css
- Call the ``.xpath()`` method for each element in this list and return
- their results flattened as another :class:`SelectorList`.
+ .. automethod:: getall
- ``query`` is the same argument as the one in :meth:`Selector.xpath`
+ See also: :ref:`old-extraction-api`
- .. method:: css(query)
+ .. automethod:: get
- Call the ``.css()`` method for each element in this list and return
- their results flattened as another :class:`SelectorList`.
+ See also: :ref:`old-extraction-api`
- ``query`` is the same argument as the one in :meth:`Selector.css`
+ .. automethod:: re
- .. method:: extract()
+ .. automethod:: re_first
- Call the ``.extract()`` method for each element in this list and return
- their results flattened, as a list of unicode strings.
+ .. autoattribute:: attrib
- .. method:: re()
+ See also: :ref:`selecting-attributes`.
- Call the ``.re()`` method for each element in this list and return
- their results flattened, as a list of unicode strings.
+.. _selector-examples:
+Examples
+========
+
+.. _selector-examples-html:
Selector examples on HTML response
----------------------------------
-Here's a couple of :class:`Selector` examples to illustrate several concepts.
+Here are some :class:`Selector` examples to illustrate several concepts.
In all cases, we assume there is already a :class:`Selector` instantiated with
a :class:`~scrapy.http.HtmlResponse` object like this::
@@ -735,20 +993,22 @@ a :class:`~scrapy.http.HtmlResponse` object like this::
2. Extract the text of all ```` elements from an HTML response body,
returning a list of unicode strings::
- sel.xpath("//h1").extract() # this includes the h1 tag
- sel.xpath("//h1/text()").extract() # this excludes the h1 tag
+ sel.xpath("//h1").getall() # this includes the h1 tag
+ sel.xpath("//h1/text()").getall() # this excludes the h1 tag
3. Iterate over all `` `` tags and print their class attribute::
for node in sel.xpath("//p"):
- print node.xpath("@class").extract()
+ print(node.attrib['class'])
+
+
+.. _selector-examples-xml:
Selector examples on XML response
---------------------------------
-Here's a couple of examples to illustrate several concepts. In both cases we
-assume there is already a :class:`Selector` instantiated with an
-:class:`~scrapy.http.XmlResponse` object like this::
+Here are some examples to illustrate concepts for :class:`Selector` objects
+instantiated with an :class:`~scrapy.http.XmlResponse` object::
sel = Selector(xml_response)
@@ -761,53 +1021,6 @@ assume there is already a :class:`Selector` instantiated with an
a namespace::
sel.register_namespace("g", "http://base.google.com/ns/1.0")
- sel.xpath("//g:price").extract()
-
-.. _removing-namespaces:
-
-Removing namespaces
--------------------
-
-When dealing with scraping projects, it is often quite convenient to get rid of
-namespaces altogether and just work with element names, to write more
-simple/convenient XPaths. You can use the
-:meth:`Selector.remove_namespaces` method for that.
-
-Let's show an example that illustrates this with GitHub blog atom feed.
-
-.. highlight:: sh
-
-First, we open the shell with the url we want to scrape::
-
- $ scrapy shell https://github.com/blog.atom
-
-.. highlight:: python
-
-Once in the shell we can try selecting all `` `` objects and see that it
-doesn't work (because the Atom XML namespace is obfuscating those nodes)::
-
- >>> response.xpath("//link")
- []
-
-But once we call the :meth:`Selector.remove_namespaces` method, all
-nodes can be accessed directly by their names::
-
- >>> response.selector.remove_namespaces()
- >>> response.xpath("//link")
- [,
- ...
-
-If you wonder why the namespace removal procedure isn't always called by default
-instead of having to call it manually, this is because of two reasons, which, in order
-of relevance, are:
-
-1. Removing namespaces requires to iterate and modify all nodes in the
- document, which is a reasonably expensive operation to perform for all
- documents crawled by Scrapy
-
-2. There could be some cases where using namespaces is actually required, in
- case some element names clash between namespaces. These cases are very rare
- though.
+ sel.xpath("//g:price").getall()
.. _Google Base XML feed: https://support.google.com/merchants/answer/160589?hl=en&ref_topic=2473799
diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst
index 076dc6bfd..4b770d249 100644
--- a/docs/topics/settings.rst
+++ b/docs/topics/settings.rst
@@ -30,6 +30,8 @@ Python `import search path`_.
.. _import search path: https://docs.python.org/2/tutorial/modules.html#the-module-search-path
+.. _populating-settings:
+
Populating the settings
=======================
@@ -158,6 +160,27 @@ to any particular component. In that case the module of that component will be
shown, typically an extension, middleware or pipeline. It also means that the
component must be enabled in order for the setting to have any effect.
+.. setting:: ASYNCIO_REACTOR
+
+ASYNCIO_REACTOR
+---------------
+
+Default: ``False``
+
+Whether to install and require the Twisted reactor that uses the asyncio loop.
+
+When this option is set to ``True``, Scrapy will require
+:class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor`. It will
+install this reactor if no reactor is installed yet, such as when using the
+``scrapy`` script or :class:`~scrapy.crawler.CrawlerProcess`. If you are using
+:class:`~scrapy.crawler.CrawlerRunner`, you need to install the correct reactor
+manually. If a different reactor is installed outside Scrapy, it will raise an
+exception.
+
+The default value for this option is currently ``False`` to maintain backward
+compatibility and avoid possible problems caused by using a different Twisted
+reactor.
+
.. setting:: AWS_ACCESS_KEY_ID
AWS_ACCESS_KEY_ID
@@ -178,35 +201,45 @@ Default: ``None``
The AWS secret key used by code that requires access to `Amazon Web services`_,
such as the :ref:`S3 feed storage backend `.
-.. setting:: BOT_NAME
+.. setting:: AWS_ENDPOINT_URL
AWS_ENDPOINT_URL
----------------
Default: ``None``
-Endpoint URL used for S3-like self-hosted storage. Storage like Minio or s3.scality.
+Endpoint URL used for S3-like storage, for example Minio or s3.scality.
-.. setting:: AWS_ENDPOINT_URL
+.. setting:: AWS_USE_SSL
AWS_USE_SSL
-----------
Default: ``None``
-Use this option if you want to disable SSL connection for communication with S3 or S3-like storage.
-By default SSL will be used.
+Use this option if you want to disable SSL connection for communication with
+S3 or S3-like storage. By default SSL will be used.
-.. setting:: AWS_USE_SSL
+.. setting:: AWS_VERIFY
AWS_VERIFY
----------
Default: ``None``
-Verify SSL connection between Scrapy and S3 or S3-like storage. By default SSL verification will occur.
+Verify SSL connection between Scrapy and S3 or S3-like storage. By default
+SSL verification will occur.
-.. setting:: AWS_VERIFY
+.. setting:: AWS_REGION_NAME
+
+AWS_REGION_NAME
+---------------
+
+Default: ``None``
+
+The name of the region associated with the AWS client.
+
+.. setting:: BOT_NAME
BOT_NAME
--------
@@ -214,8 +247,7 @@ BOT_NAME
Default: ``'scrapybot'``
The name of the bot implemented by this Scrapy project (also known as the
-project name). This will be used to construct the User-Agent by default, and
-also for logging.
+project name). This name will be used for the logging too.
It's automatically populated with your project name when you create your
project with the :command:`startproject` command.
@@ -318,16 +350,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::
@@ -335,17 +367,6 @@ See also: :ref:`faq-bfo-dfo` about tuning Scrapy for BFO or DFO.
other priority settings :setting:`REDIRECT_PRIORITY_ADJUST`
and :setting:`RETRY_PRIORITY_ADJUST`.
-.. setting:: DEPTH_STATS
-
-DEPTH_STATS
------------
-
-Default: ``True``
-
-Scope: ``scrapy.spidermiddlewares.depth.DepthMiddleware``
-
-Whether to collect maximum depth stats.
-
.. setting:: DEPTH_STATS_VERBOSE
DEPTH_STATS_VERBOSE
@@ -376,6 +397,19 @@ Default: ``10000``
DNS in-memory cache size.
+.. setting:: DNS_RESOLVER
+
+DNS_RESOLVER
+------------
+
+Default: ``'scrapy.resolver.CachingThreadedResolver'``
+
+The class to be used to resolve DNS names. The default ``scrapy.resolver.CachingThreadedResolver``
+supports specifying a timeout for DNS requests via the :setting:`DNS_TIMEOUT` setting,
+but works only with IPv4 addresses. Scrapy provides an alternative resolver,
+``scrapy.resolver.CachingHostnameResolver``, which supports IPv4/IPv6 addresses but does not
+take the :setting:`DNS_TIMEOUT` setting into account.
+
.. setting:: DNS_TIMEOUT
DNS_TIMEOUT
@@ -436,9 +470,29 @@ or even enable client-side authentication (and various other things).
which uses the platform's certificates to validate remote endpoints.
**This is only available if you use Twisted>=14.0.**
-If you do use a custom ContextFactory, make sure it accepts a ``method``
-parameter at init (this is the ``OpenSSL.SSL`` method mapping
-:setting:`DOWNLOADER_CLIENT_TLS_METHOD`).
+If you do use a custom ContextFactory, make sure its ``__init__`` method
+accepts a ``method`` parameter (this is the ``OpenSSL.SSL`` method mapping
+:setting:`DOWNLOADER_CLIENT_TLS_METHOD`), a ``tls_verbose_logging``
+parameter (``bool``) and a ``tls_ciphers`` parameter (see
+:setting:`DOWNLOADER_CLIENT_TLS_CIPHERS`).
+
+.. setting:: DOWNLOADER_CLIENT_TLS_CIPHERS
+
+DOWNLOADER_CLIENT_TLS_CIPHERS
+-----------------------------
+
+Default: ``'DEFAULT'``
+
+Use this setting to customize the TLS/SSL ciphers used by the default
+HTTP/1.1 downloader.
+
+The setting should contain a string in the `OpenSSL cipher list format`_,
+these ciphers will be used as client ciphers. Changing this setting may be
+necessary to access certain HTTPS websites: for example, you may need to use
+``'DEFAULT:!DH'`` for a website with weak DH parameters or enable a
+specific cipher that is not included in ``DEFAULT`` if a website requires it.
+
+.. _OpenSSL cipher list format: https://www.openssl.org/docs/manmaster/man1/ciphers.html#CIPHER-LIST-FORMAT
.. setting:: DOWNLOADER_CLIENT_TLS_METHOD
@@ -466,6 +520,20 @@ This setting must be one of these string values:
We recommend that you use PyOpenSSL>=0.13 and Twisted>=0.13
or above (Twisted>=14.0 if you can).
+.. setting:: DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING
+
+DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING
+-------------------------------------
+
+Default: ``False``
+
+Setting this to ``True`` will enable DEBUG level messages about TLS connection
+parameters after establishing HTTPS connections. The kind of information logged
+depends on the versions of OpenSSL and pyOpenSSL.
+
+This setting is only used for the default
+:setting:`DOWNLOADER_CLIENTCONTEXTFACTORY`.
+
.. setting:: DOWNLOADER_MIDDLEWARES
DOWNLOADER_MIDDLEWARES
@@ -536,6 +604,8 @@ amount of time between requests, but uses a random interval between 0.5 * :setti
When :setting:`CONCURRENT_REQUESTS_PER_IP` is non-zero, delays are enforced
per ip address instead of per domain.
+.. _spider-download_delay-attribute:
+
You can also change this setting per spider by setting ``download_delay``
spider attribute.
@@ -597,7 +667,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.
@@ -618,7 +688,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.
@@ -756,6 +826,7 @@ Default: ``True``
Whether or not to use passive mode when initiating FTP transfers.
+.. reqmeta:: ftp_password
.. setting:: FTP_PASSWORD
FTP_PASSWORD
@@ -774,6 +845,7 @@ in ``Request`` meta.
.. _RFC 1635: https://tools.ietf.org/html/rfc1635
+.. reqmeta:: ftp_user
.. setting:: FTP_USER
FTP_USER
@@ -846,7 +918,7 @@ LOG_FORMAT
Default: ``'%(asctime)s [%(name)s] %(levelname)s: %(message)s'``
-String for formatting log messsages. Refer to the `Python logging documentation`_ for the whole list of available
+String for formatting log messages. Refer to the `Python logging documentation`_ for the whole list of available
placeholders.
.. _Python logging documentation: https://docs.python.org/2/library/logging.html#logrecord-attributes
@@ -864,6 +936,15 @@ directives.
.. _Python datetime documentation: https://docs.python.org/2/library/datetime.html#strftime-and-strptime-behavior
+.. setting:: LOG_FORMATTER
+
+LOG_FORMATTER
+-------------
+
+Default: :class:`scrapy.logformatter.LogFormatter`
+
+The class to use for :ref:`formatting log messages ` for different actions.
+
.. setting:: LOG_LEVEL
LOG_LEVEL
@@ -882,7 +963,7 @@ LOG_STDOUT
Default: ``False``
If ``True``, all standard output (and error) of your process will be redirected
-to the log. For example if you ``print 'hello'`` it will appear in the Scrapy
+to the log. For example if you ``print('hello')`` it will appear in the Scrapy
log.
.. setting:: LOG_SHORT_NAMES
@@ -895,6 +976,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:`~scrapy.extensions.logstats.LogStats`.
+
.. setting:: MEMDEBUG_ENABLED
MEMDEBUG_ENABLED
@@ -1101,6 +1192,28 @@ If enabled, Scrapy will respect robots.txt policies. For more information see
this option is enabled by default in settings.py file generated
by ``scrapy startproject`` command.
+.. setting:: ROBOTSTXT_PARSER
+
+ROBOTSTXT_PARSER
+----------------
+
+Default: ``'scrapy.robotstxt.ProtegoRobotParser'``
+
+The parser backend to use for parsing ``robots.txt`` files. For more information see
+:ref:`topics-dlmw-robots`.
+
+.. setting:: ROBOTSTXT_USER_AGENT
+
+ROBOTSTXT_USER_AGENT
+^^^^^^^^^^^^^^^^^^^^
+
+Default: ``None``
+
+The user agent string to use for matching in the robots.txt file. If ``None``,
+the User-Agent header you are sending with the request or the
+:setting:`USER_AGENT` setting (in that order) will be used for determining
+the user agent to use in the robots.txt file.
+
.. setting:: SCHEDULER
SCHEDULER
@@ -1153,9 +1266,25 @@ 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.
+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
+domains in parallel. But currently ``scrapy.pqueues.DownloaderAwarePriorityQueue``
+does not work together with :setting:`CONCURRENT_REQUESTS_PER_IP`.
+
+.. setting:: SCRAPER_SLOT_MAX_ACTIVE_SIZE
+
+SCRAPER_SLOT_MAX_ACTIVE_SIZE
+----------------------------
+Default: ``5_000_000``
+
+Soft limit (in bytes) for response data being processed.
+
+While the sum of the sizes of all responses being processed is above this value,
+Scrapy does not process new requests.
.. setting:: SPIDER_CONTRACTS
@@ -1180,7 +1309,7 @@ Default::
'scrapy.contracts.default.ScrapesContract': 3,
}
-A dict containing the scrapy contracts enabled by default in Scrapy. You should
+A dict containing the Scrapy contracts enabled by default in Scrapy. You should
never modify this setting in your project, modify :setting:`SPIDER_CONTRACTS`
instead. For more info see :ref:`topics-contracts`.
@@ -1211,7 +1340,7 @@ SPIDER_LOADER_WARN_ONLY
Default: ``False``
-By default, when scrapy tries to import spider classes from :setting:`SPIDER_MODULES`,
+By default, when Scrapy tries to import spider classes from :setting:`SPIDER_MODULES`,
it will fail loudly if there is any ``ImportError`` exception.
But you can choose to silence this exception and turn it into a simple
warning by setting ``SPIDER_LOADER_WARN_ONLY = True``.
@@ -1354,7 +1483,10 @@ USER_AGENT
Default: ``"Scrapy/VERSION (+https://scrapy.org)"``
-The default User-Agent to use when crawling, unless overridden.
+The default User-Agent to use when crawling, unless overridden. This user agent is
+also used by :class:`~scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware`
+if :setting:`ROBOTSTXT_USER_AGENT` setting is ``None`` and
+there is no overridding User-Agent header specified for the request.
Settings documented elsewhere:
diff --git a/docs/topics/shell.rst b/docs/topics/shell.rst
index 11ab199f2..3cf8311a6 100644
--- a/docs/topics/shell.rst
+++ b/docs/topics/shell.rst
@@ -31,7 +31,7 @@ for more info.
Scrapy also has support for `bpython`_, and will try to use it where `IPython`_
is unavailable.
-Through scrapy's settings you can configure it to use any one of
+Through Scrapy's settings you can configure it to use any one of
``ipython``, ``bpython`` or the standard ``python`` shell, regardless of which
are installed. This is done by setting the ``SCRAPY_PYTHON_SHELL`` environment
variable; or by defining it in your :ref:`scrapy.cfg `::
@@ -177,47 +177,46 @@ all start with the ``[s]`` prefix)::
>>>
-After that, we can start playing with the objects::
+After that, we can start playing with the objects:
- >>> response.xpath('//title/text()').extract_first()
- 'Scrapy | A Fast and Powerful Scraping and Web Crawling Framework'
+>>> response.xpath('//title/text()').get()
+'Scrapy | A Fast and Powerful Scraping and Web Crawling Framework'
- >>> fetch("https://reddit.com")
+>>> fetch("https://reddit.com")
- >>> response.xpath('//title/text()').extract()
- ['reddit: the front page of the internet']
+>>> response.xpath('//title/text()').get()
+'reddit: the front page of the internet'
- >>> request = request.replace(method="POST")
+>>> request = request.replace(method="POST")
- >>> fetch(request)
+>>> fetch(request)
- >>> response.status
- 404
+>>> response.status
+404
- >>> from pprint import pprint
+>>> from pprint import pprint
- >>> pprint(response.headers)
- {'Accept-Ranges': ['bytes'],
- 'Cache-Control': ['max-age=0, must-revalidate'],
- 'Content-Type': ['text/html; charset=UTF-8'],
- 'Date': ['Thu, 08 Dec 2016 16:21:19 GMT'],
- 'Server': ['snooserv'],
- 'Set-Cookie': ['loid=KqNLou0V9SKMX4qb4n; Domain=reddit.com; Max-Age=63071999; Path=/; expires=Sat, 08-Dec-2018 16:21:19 GMT; secure',
- 'loidcreated=2016-12-08T16%3A21%3A19.445Z; Domain=reddit.com; Max-Age=63071999; Path=/; expires=Sat, 08-Dec-2018 16:21:19 GMT; secure',
- 'loid=vi0ZVe4NkxNWdlH7r7; Domain=reddit.com; Max-Age=63071999; Path=/; expires=Sat, 08-Dec-2018 16:21:19 GMT; secure',
- 'loidcreated=2016-12-08T16%3A21%3A19.459Z; Domain=reddit.com; Max-Age=63071999; Path=/; expires=Sat, 08-Dec-2018 16:21:19 GMT; secure'],
- 'Vary': ['accept-encoding'],
- 'Via': ['1.1 varnish'],
- 'X-Cache': ['MISS'],
- 'X-Cache-Hits': ['0'],
- 'X-Content-Type-Options': ['nosniff'],
- 'X-Frame-Options': ['SAMEORIGIN'],
- 'X-Moose': ['majestic'],
- 'X-Served-By': ['cache-cdg8730-CDG'],
- 'X-Timer': ['S1481214079.394283,VS0,VE159'],
- 'X-Ua-Compatible': ['IE=edge'],
- 'X-Xss-Protection': ['1; mode=block']}
- >>>
+>>> pprint(response.headers)
+{'Accept-Ranges': ['bytes'],
+ 'Cache-Control': ['max-age=0, must-revalidate'],
+ 'Content-Type': ['text/html; charset=UTF-8'],
+ 'Date': ['Thu, 08 Dec 2016 16:21:19 GMT'],
+ 'Server': ['snooserv'],
+ 'Set-Cookie': ['loid=KqNLou0V9SKMX4qb4n; Domain=reddit.com; Max-Age=63071999; Path=/; expires=Sat, 08-Dec-2018 16:21:19 GMT; secure',
+ 'loidcreated=2016-12-08T16%3A21%3A19.445Z; Domain=reddit.com; Max-Age=63071999; Path=/; expires=Sat, 08-Dec-2018 16:21:19 GMT; secure',
+ 'loid=vi0ZVe4NkxNWdlH7r7; Domain=reddit.com; Max-Age=63071999; Path=/; expires=Sat, 08-Dec-2018 16:21:19 GMT; secure',
+ 'loidcreated=2016-12-08T16%3A21%3A19.459Z; Domain=reddit.com; Max-Age=63071999; Path=/; expires=Sat, 08-Dec-2018 16:21:19 GMT; secure'],
+ 'Vary': ['accept-encoding'],
+ 'Via': ['1.1 varnish'],
+ 'X-Cache': ['MISS'],
+ 'X-Cache-Hits': ['0'],
+ 'X-Content-Type-Options': ['nosniff'],
+ 'X-Frame-Options': ['SAMEORIGIN'],
+ 'X-Moose': ['majestic'],
+ 'X-Served-By': ['cache-cdg8730-CDG'],
+ 'X-Timer': ['S1481214079.394283,VS0,VE159'],
+ 'X-Ua-Compatible': ['IE=edge'],
+ 'X-Xss-Protection': ['1; mode=block']}
.. _topics-shell-inspect-response:
@@ -263,16 +262,16 @@ When you run the spider, you will get something similar to this::
>>> response.url
'http://example.org'
-Then, you can check if the extraction code is working::
+Then, you can check if the extraction code is working:
- >>> response.xpath('//h1[@class="fn"]')
- []
+>>> response.xpath('//h1[@class="fn"]')
+[]
Nope, it doesn't. So you can open the response in your web browser and see if
-it's the response you were expecting::
+it's the response you were expecting:
- >>> view(response)
- True
+>>> view(response)
+True
Finally you hit Ctrl-D (or Ctrl-Z in Windows) to exit the shell and resume the
crawling::
diff --git a/docs/topics/signals.rst b/docs/topics/signals.rst
index cf1588df8..3f29aa323 100644
--- a/docs/topics/signals.rst
+++ b/docs/topics/signals.rst
@@ -50,10 +50,10 @@ Here is a simple example showing how you can catch signals and perform some acti
Deferred signal handlers
========================
-Some signals support returning `Twisted deferreds`_ from their handlers, see
-the :ref:`topics-signals-ref` below to know which ones.
+Some signals support returning :class:`~twisted.internet.defer.Deferred`
+objects from their handlers, see the :ref:`topics-signals-ref` below to know
+which ones.
-.. _Twisted deferreds: https://twistedmatrix.com/documents/current/core/howto/defer.html
.. _topics-signals-ref:
@@ -135,6 +135,29 @@ item_dropped
to be dropped
:type exception: :exc:`~scrapy.exceptions.DropItem` exception
+item_error
+------------
+
+.. signal:: item_error
+.. function:: item_error(item, response, spider, failure)
+
+ Sent when a :ref:`topics-item-pipeline` generates an error (ie. raises
+ an exception), except :exc:`~scrapy.exceptions.DropItem` exception.
+
+ This signal supports returning deferreds from their handlers.
+
+ :param item: the item dropped from the :ref:`topics-item-pipeline`
+ :type item: dict or :class:`~scrapy.item.Item` object
+
+ :param response: the response being processed when the exception was raised
+ :type response: :class:`~scrapy.http.Response` object
+
+ :param spider: the spider which raised the exception
+ :type spider: :class:`~scrapy.spiders.Spider` object
+
+ :param failure: the exception raised
+ :type failure: twisted.python.failure.Failure
+
spider_closed
-------------
@@ -213,8 +236,8 @@ spider_error
This signal does not support returning deferreds from their handlers.
- :param failure: the exception raised as a Twisted `Failure`_ object
- :type failure: `Failure`_ object
+ :param failure: the exception raised
+ :type failure: twisted.python.failure.Failure
:param response: the response being processed when the exception was raised
:type response: :class:`~scrapy.http.Response` object
@@ -256,6 +279,22 @@ request_dropped
:param spider: the spider that yielded the request
:type spider: :class:`~scrapy.spiders.Spider` object
+request_reached_downloader
+---------------------------
+
+.. signal:: request_reached_downloader
+.. function:: request_reached_downloader(request, spider)
+
+ Sent when a :class:`~scrapy.http.Request` reached downloader.
+
+ 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
-----------------
@@ -294,5 +333,3 @@ response_downloaded
:param spider: the spider for which the response is intended
:type spider: :class:`~scrapy.spiders.Spider` object
-
-.. _Failure: https://twistedmatrix.com/documents/current/api/twisted.python.failure.Failure.html
diff --git a/docs/topics/spider-middleware.rst b/docs/topics/spider-middleware.rst
index c297ed556..0e8210130 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 = {
@@ -54,11 +54,17 @@ value. For example, if you want to disable the off-site middleware::
Finally, keep in mind that some middlewares may need to be enabled through a
particular setting. See each middleware documentation for more info.
+.. _custom-spider-middleware:
+
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
@@ -78,7 +84,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.
@@ -112,11 +119,11 @@ 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 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.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 +132,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 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
raised
@@ -200,7 +208,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.
@@ -212,7 +220,8 @@ DepthMiddleware
* :setting:`DEPTH_LIMIT` - The maximum depth that will be allowed to
crawl for any site. If zero, no limit will be imposed.
- * :setting:`DEPTH_STATS` - Whether to collect depth stats.
+ * :setting:`DEPTH_STATS_VERBOSE` - Whether to collect the number of
+ requests for each depth.
* :setting:`DEPTH_PRIORITY` - Whether to prioritize the requests based on
their depth.
diff --git a/docs/topics/spiders.rst b/docs/topics/spiders.rst
index c2c271245..b0fb14e24 100644
--- a/docs/topics/spiders.rst
+++ b/docs/topics/spiders.rst
@@ -72,8 +72,6 @@ scrapy.Spider
spider that crawls ``mywebsite.com`` would often be called
``mywebsite``.
- .. note:: In Python 2 this must be ASCII only.
-
.. attribute:: allowed_domains
An optional list of strings containing domains that this spider is
@@ -88,7 +86,7 @@ scrapy.Spider
A list of URLs where the spider will begin to crawl from, when no
particular URLs are specified. So, the first pages downloaded will be those
- listed here. The subsequent URLs will be generated successively from data
+ listed here. The subsequent :class:`~scrapy.http.Request` will be generated successively from data
contained in the start URLs.
.. attribute:: custom_settings
@@ -129,7 +127,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
@@ -190,7 +188,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)
@@ -229,11 +227,11 @@ Return multiple Requests and items from a single callback::
]
def parse(self, response):
- for h3 in response.xpath('//h3').extract():
+ for h3 in response.xpath('//h3').getall():
yield {"title": h3}
- for url in response.xpath('//a/@href').extract():
- yield scrapy.Request(url, callback=self.parse)
+ for href in response.xpath('//a/@href').getall():
+ yield scrapy.Request(response.urljoin(href), self.parse)
Instead of :attr:`~.start_urls` you can use :meth:`~.start_requests` directly;
to give data more structure you can use :ref:`topics-items`::
@@ -251,11 +249,11 @@ to give data more structure you can use :ref:`topics-items`::
yield scrapy.Request('http://www.example.com/3.html', self.parse)
def parse(self, response):
- for h3 in response.xpath('//h3').extract():
+ for h3 in response.xpath('//h3').getall():
yield MyItem(title=h3)
- for url in response.xpath('//a/@href').extract():
- yield scrapy.Request(url, callback=self.parse)
+ for href in response.xpath('//a/@href').getall():
+ yield scrapy.Request(response.urljoin(href), self.parse)
.. _spiderargs:
@@ -298,13 +296,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.
@@ -374,16 +372,23 @@ CrawlSpider
Crawling rules
~~~~~~~~~~~~~~
-.. class:: Rule(link_extractor, callback=None, cb_kwargs=None, follow=None, process_links=None, process_request=None)
+.. autoclass:: Rule
``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`` dictionary (under the ``link_text`` key).
+ If omitted, a default link extractor created with no arguments will be used,
+ resulting in all links being extracted.
``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). As mentioned above, the received :class:`~scrapy.http.Response`
+ object will contain the text of the link that produced the :class:`~scrapy.http.Request`
+ in its ``meta`` dictionary (under the ``link_text`` key)
.. warning:: When writing crawl spider rules, avoid using ``parse`` as
callback, since the :class:`CrawlSpider` uses the ``parse`` method
@@ -402,10 +407,18 @@ 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
+ :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).
+
+ ``errback`` is a callable or a string (in which case a method from the spider
+ object with that name will be used) to be called if any exception is
+ raised while processing a request generated by the rule.
+ It receives a :class:`Twisted Failure `
+ instance as first parameter.
CrawlSpider example
~~~~~~~~~~~~~~~~~~~
@@ -434,8 +447,9 @@ Let's now take a look at an example CrawlSpider with rules::
self.logger.info('Hi, this is an item page! %s', response.url)
item = scrapy.Item()
item['id'] = response.xpath('//td[@id="item_id"]/text()').re(r'ID: (\d+)')
- item['name'] = response.xpath('//td[@id="item_name"]/text()').extract()
- item['description'] = response.xpath('//td[@id="item_description"]/text()').extract()
+ 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
@@ -545,12 +559,12 @@ These spiders are pretty easy to use, let's have a look at one example::
itertag = 'item'
def parse_node(self, response, node):
- self.logger.info('Hi, this is a <%s> node!: %s', self.itertag, ''.join(node.extract()))
+ self.logger.info('Hi, this is a <%s> node!: %s', self.itertag, ''.join(node.getall()))
item = TestItem()
- item['id'] = node.xpath('@id').extract()
- item['name'] = node.xpath('name').extract()
- item['description'] = node.xpath('description').extract()
+ item['id'] = node.xpath('@id').get()
+ item['name'] = node.xpath('name').get()
+ item['description'] = node.xpath('description').get()
return item
Basically what we did up there was to create a spider that downloads a feed from
@@ -655,7 +669,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.
@@ -680,6 +694,50 @@ SitemapSpider
Default is ``sitemap_alternate_links`` disabled.
+ .. method:: sitemap_filter(entries)
+
+ This is a filter function that could be overridden to select sitemap entries
+ based on their attributes.
+
+ For example::
+
+
+ http://example.com/
+ 2005-01-01
+
+
+ We can define a ``sitemap_filter`` function to filter ``entries`` by date::
+
+ from datetime import datetime
+ from scrapy.spiders import SitemapSpider
+
+ class FilteredSitemapSpider(SitemapSpider):
+ name = 'filtered_sitemap_spider'
+ allowed_domains = ['example.com']
+ sitemap_urls = ['http://example.com/sitemap.xml']
+
+ def sitemap_filter(self, entries):
+ for entry in entries:
+ date_time = datetime.strptime(entry['lastmod'], '%Y-%m-%d')
+ if date_time.year >= 2005:
+ yield entry
+
+ This would retrieve only ``entries`` modified on 2005 and the following
+ years.
+
+ Entries are dict objects extracted from the sitemap document.
+ Usually, the key is the tag name and the value is the text inside it.
+
+ It's important to notice that:
+
+ - as the loc attribute is required, entries without this tag are discarded
+ - alternate links are stored in a list with the key ``alternate``
+ (see ``sitemap_alternate_links``)
+ - namespaces are removed, so lxml tags named as ``{namespace}tagname`` become only ``tagname``
+
+ If you omit this method, all entries found in sitemaps will be
+ processed, observing other attributes and their settings.
+
SitemapSpider examples
~~~~~~~~~~~~~~~~~~~~~~
diff --git a/docs/topics/stats.rst b/docs/topics/stats.rst
index dd0c6216b..3dd829ebe 100644
--- a/docs/topics/stats.rst
+++ b/docs/topics/stats.rst
@@ -57,15 +57,15 @@ Set stat value only if lower than previous::
stats.min_value('min_free_memory_percent', value)
-Get stat value::
+Get stat value:
- >>> stats.get_value('custom_count')
- 1
+>>> stats.get_value('custom_count')
+1
-Get all stats::
+Get all stats:
- >>> stats.get_stats()
- {'custom_count': 1, 'start_time': datetime.datetime(2009, 7, 14, 21, 47, 28, 977139)}
+>>> stats.get_stats()
+{'custom_count': 1, 'start_time': datetime.datetime(2009, 7, 14, 21, 47, 28, 977139)}
Available Stats Collectors
==========================
@@ -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 ce79c9f35..47d8d393c 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.
@@ -16,6 +15,17 @@ The telnet console is a :ref:`built-in Scrapy extension
disable it if you want. For more information about the extension itself see
:ref:`topics-extensions-ref-telnetconsole`.
+.. warning::
+ It is not secure to use telnet console via public networks, as telnet
+ doesn't provide any transport-layer security. Having username/password
+ authentication doesn't change that.
+
+ Intended usage is connecting to a running Scrapy spider locally
+ (spider process and telnet client are on the same machine)
+ or over a secure connection (VPN, SSH tunnel).
+ Please avoid using telnet console over insecure connections,
+ or disable it completely using :setting:`TELNETCONSOLE_ENABLED` option.
+
.. highlight:: none
How to access the telnet console
@@ -26,8 +36,26 @@ The telnet console listens in the TCP port defined in the
the console you need to type::
telnet localhost 6023
+ Trying localhost...
+ Connected to localhost.
+ Escape character is '^]'.
+ Username:
+ Password:
>>>
-
+
+By default Username is ``scrapy`` and Password is autogenerated. The
+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
+
+Default Username and Password can be overridden by the settings
+:setting:`TELNETCONSOLE_USERNAME` and :setting:`TELNETCONSOLE_PASSWORD`.
+
+.. warning::
+ Username and password provide only a limited protection, as telnet
+ is not using secure transport - by default traffic is not encrypted
+ even if username and password are set.
+
You need the telnet program which comes installed by default in Windows, and
most Linux distros.
@@ -160,3 +188,24 @@ Default: ``'127.0.0.1'``
The interface the telnet console should listen on
+
+.. setting:: TELNETCONSOLE_USERNAME
+
+TELNETCONSOLE_USERNAME
+----------------------
+
+Default: ``'scrapy'``
+
+The username used for the telnet console
+
+
+.. setting:: TELNETCONSOLE_PASSWORD
+
+TELNETCONSOLE_PASSWORD
+----------------------
+
+Default: ``None``
+
+The password used for the telnet console, default behaviour is to have it
+autogenerated
+
diff --git a/docs/topics/ubuntu.rst b/docs/topics/ubuntu.rst
deleted file mode 100644
index 81ce800aa..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
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/extras/qps-bench-server.py b/extras/qps-bench-server.py
index 3bef20bf3..da7a0022b 100755
--- a/extras/qps-bench-server.py
+++ b/extras/qps-bench-server.py
@@ -1,5 +1,4 @@
#!/usr/bin/env python
-from __future__ import print_function
from time import time
from collections import deque
from twisted.web.server import Site, NOT_DONE_YET
diff --git a/extras/scrapy_zsh_completion b/extras/scrapy_zsh_completion
index 564991aa8..e995947cb 100644
--- a/extras/scrapy_zsh_completion
+++ b/extras/scrapy_zsh_completion
@@ -1,25 +1,210 @@
#compdef scrapy
-
-# zsh completion for the Scrapy command-line tool
-
_scrapy() {
- local curcontext="$curcontext" cmd spiders
+ local context state state_descr line
typeset -A opt_args
- cmd=$words[2]
-
- case "$cmd" in
- crawl|edit|check)
- spiders=$(scrapy list 2>/dev/null) || spiders=""
- if [[ -n "$spiders" ]]; then
- compadd `echo $spiders`
- fi
- ;;
- *)
- if [[ CURRENT -eq 2 ]]; then
- _arguments '*: :(check crawl edit fetch genspider list parse runspider settings shell startproject version view)'
- fi
- ;;
+ _arguments \
+ "(- 1 *)--help[Help]" \
+ "1: :->command" \
+ "*:: :->args"
+
+ case $state in
+ command)
+ _scrapy_cmds
+ ;;
+ args)
+ case $words[1] in
+ bench)
+ _scrapy_glb_opts
+ ;;
+ fetch)
+ local options=(
+ '--headers[print response HTTP headers instead of body]'
+ '--no-redirect[do not handle HTTP 3xx status codes and print response as-is]'
+ '--spider[use this spider]:spider:_scrapy_spiders'
+ '1::URL:_httpie_urls'
+ )
+ _scrapy_glb_opts $options
+ ;;
+ genspider)
+ local options=(
+ {-l,--list}'[List available templates]'
+ {-e,--edit}'[Edit spider after creating it]'
+ '--force[If the spider already exists, overwrite it with the template]'
+ {-d,--dump=}'[Dump template to standard output]:template:(basic crawl csvfeed xmlfeed)'
+ {-t,--template=}'[Uses a custom template]:template:(basic crawl csvfeed xmlfeed)'
+ '1:name:(NAME)'
+ '2:domain:_httpie_urls'
+ )
+ _scrapy_glb_opts $options
+ ;;
+ runspider)
+ local options=(
+ {-o,--output}'[dump scraped items into FILE (use - for stdout)]:file:_files'
+ {-t,--output-format}'[format to use for dumping items with -o]:format:(FORMAT)'
+ '*-a[set spider argument (may be repeated)]:value pair:(NAME=VALUE)'
+ '1:spider file:_files -g \*.py'
+ )
+ _scrapy_glb_opts $options
+ ;;
+ settings)
+ local options=(
+ '--get=[print raw setting value]:option:(SETTING)'
+ '--getbool=[print setting value, interpreted as a boolean]:option:(SETTING)'
+ '--getint=[print setting value, interpreted as an integer]:option:(SETTING)'
+ '--getfloat=[print setting value, interpreted as a float]:option:(SETTING)'
+ '--getlist=[print setting value, interpreted as a list]:option:(SETTING)'
+ )
+ _scrapy_glb_opts $options
+ ;;
+ shell)
+ local options=(
+ '-c[evaluate the code in the shell, print the result and exit]:code:(CODE)'
+ '--no-redirect[do not handle HTTP 3xx status codes and print response as-is]'
+ '--spider[use this spider]:spider:_scrapy_spiders'
+ '::file:_files -g \*.html'
+ '::URL:_httpie_urls'
+ )
+ _scrapy_glb_opts $options
+ ;;
+ startproject)
+ local options=(
+ '1:name:(NAME)'
+ '2:dir:_dir_list'
+ )
+ _scrapy_glb_opts $options
+ ;;
+ version)
+ local options=(
+ {-v,--verbose}'[also display twisted/python/platform info (useful for bug reports)]'
+ )
+ _scrapy_glb_opts $options
+ ;;
+ view)
+ local options=(
+ '--no-redirect[do not handle HTTP 3xx status codes and print response as-is]'
+ '--spider[use this spider]:spider:_scrapy_spiders'
+ '1:URL:_httpie_urls'
+ )
+ _scrapy_glb_opts $options
+ ;;
+ check)
+ local options=(
+ '(- 1 *)'{-l,--list}'[only list contracts, without checking them]'
+ {-v,--verbose}'[print contract tests for all spiders]'
+ '1:spider:_scrapy_spiders'
+ )
+ _scrapy_glb_opts $options
+ ;;
+ crawl)
+ local options=(
+ {-o,--output}'[dump scraped items into FILE (use - for stdout)]:file:_files'
+ {-t,--output-format}'[format to use for dumping items with -o]:format:(FORMAT)'
+ '*-a[set spider argument (may be repeated)]:value pair:(NAME=VALUE)'
+ '1:spider:_scrapy_spiders'
+ )
+ _scrapy_glb_opts $options
+ ;;
+ edit)
+ local options=(
+ '1:spider:_scrapy_spiders'
+ )
+ _scrapy_glb_opts $options
+ ;;
+ list)
+ _scrapy_glb_opts
+ ;;
+ parse)
+ local options=(
+ '*-a[set spider argument (may be repeated)]:value pair:(NAME=VALUE)'
+ '--spider[use this spider without looking for one]:spider:_scrapy_spiders'
+ '--pipelines[process items through pipelines]'
+ "--nolinks[don't show links to follow (extracted requests)]"
+ "--noitems[don't show scraped items]"
+ '--nocolour[avoid using pygments to colorize the output]'
+ {-r,--rules}'[use CrawlSpider rules to discover the callback]'
+ {-c,--callback=}'[use this callback for parsing, instead looking for a callback]:callback:(CALLBACK)'
+ {-m,--meta=}'[inject extra meta into the Request, it must be a valid raw json string]:meta:(META)'
+ '--cbkwargs=[inject extra callback kwargs into the Request, it must be a valid raw json string]:arguments:(CBKWARGS)'
+ {-d,--depth=}'[maximum depth for parsing requests (default: 1)]:depth:(DEPTH)'
+ {-v,--verbose}'[print each depth level one by one]'
+ '1:URL:_httpie_urls'
+ )
+ _scrapy_glb_opts $options
+ ;;
+ esac
+ ;;
esac
}
-_scrapy
\ No newline at end of file
+_scrapy_cmds() {
+ local -a commands project_commands
+ commands=(
+ 'bench:Run quick benchmark test'
+ 'fetch:Fetch a URL using the Scrapy downloader'
+ 'genspider:Generate new spider using pre-defined templates'
+ 'runspider:Run a self-contained spider (without creating a project)'
+ 'settings:Get settings values'
+ 'shell:Interactive scraping console'
+ 'startproject:Create new project'
+ 'version:Print Scrapy version'
+ 'view:Open URL in browser, as seen by Scrapy'
+ )
+ project_commands=(
+ 'check:Check spider contracts'
+ 'crawl:Run a spider'
+ 'edit:Edit spider'
+ 'list:List available spiders'
+ 'parse:Parse URL (using its spider) and print the results'
+ )
+ if [[ $(scrapy -h | grep -s "no active project") == "" ]]; then
+ commands=(${commands[@]} ${project_commands[@]})
+ fi
+ _describe -t common-commands 'common commands' commands
+}
+
+_scrapy_glb_opts() {
+ local -a options
+ options=(
+ '(- *)'{-h,--help}'[show this help message and exit]'
+ '(--nolog)--logfile=[log file. if omitted stderr will be used]:file:_files'
+ '--pidfile=[write process ID to FILE]:file:_files'
+ '--profile=[write python cProfile stats to FILE]:file:_files'
+ '(--nolog)'{-L,--loglevel=}'[log level (default: INFO)]:log level:(DEBUG INFO WARN ERROR)'
+ '(-L --loglevel --logfile)--nolog[disable logging completely]'
+ '--pdb[enable pdb on failure]'
+ '*'{-s,--set=}'[set/override setting (may be repeated)]:value pair:(NAME=VALUE)'
+ )
+ options=(${options[@]} "$@")
+ _arguments $options
+}
+
+_httpie_urls() {
+
+ local ret=1
+
+ if ! [[ -prefix [-+.a-z0-9]#:// ]]; then
+ local expl
+ compset -S '[^:/]*' && compstate[to_end]=''
+ _wanted url-schemas expl 'URL schema' compadd -S '' http:// https:// && ret=0
+ else
+ _urls && ret=0
+ fi
+
+ return $ret
+
+}
+
+_scrapy_spiders() {
+
+ local ret=1
+
+ if [[ $(scrapy -h | grep -s "no active project") == "" ]]; then
+ compadd -S '' $(scrapy list) && ret=0
+ else
+ compadd -S '' SPIDER && ret=0
+ fi
+
+ return $ret
+}
+
+_scrapy $@
diff --git a/pytest.ini b/pytest.ini
index 73d169601..bae68cd3a 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -2,5 +2,252 @@
usefixtures = chdir
python_files=test_*.py __init__.py
python_classes=
-addopts = --doctest-modules --assert=plain
+addopts =
+ --assert=plain
+ --doctest-modules
+ --ignore=docs/_ext
+ --ignore=docs/conf.py
+ --ignore=docs/news.rst
+ --ignore=docs/topics/dynamic-content.rst
+ --ignore=docs/topics/items.rst
+ --ignore=docs/topics/leaks.rst
+ --ignore=docs/topics/loaders.rst
+ --ignore=docs/topics/selectors.rst
+ --ignore=docs/topics/shell.rst
+ --ignore=docs/topics/stats.rst
+ --ignore=docs/topics/telnetconsole.rst
+ --ignore=docs/utils
twisted = 1
+markers =
+ only_asyncio: marks tests as only enabled when --reactor=asyncio is passed
+flake8-ignore =
+ # Files that are only meant to provide top-level imports are expected not
+ # to use any of their imports:
+ scrapy/core/downloader/handlers/http.py F401
+ scrapy/http/__init__.py F401
+ # Issues pending a review:
+ # extras
+ extras/qps-bench-server.py E501
+ extras/qpsclient.py E501 E501
+ # scrapy/commands
+ scrapy/commands/__init__.py E128 E501
+ scrapy/commands/check.py E501
+ scrapy/commands/crawl.py E501
+ scrapy/commands/edit.py E501
+ scrapy/commands/fetch.py E401 E501 E128 E731
+ scrapy/commands/genspider.py E128 E501 E502
+ scrapy/commands/parse.py E128 E501 E731 E226
+ scrapy/commands/runspider.py E501
+ scrapy/commands/settings.py E128
+ scrapy/commands/shell.py E128 E501 E502
+ scrapy/commands/startproject.py E127 E501 E128
+ scrapy/commands/version.py E501 E128
+ # scrapy/contracts
+ scrapy/contracts/__init__.py E501 W504
+ scrapy/contracts/default.py E128
+ # scrapy/core
+ scrapy/core/engine.py E501 E128 E127 E306 E502
+ scrapy/core/scheduler.py E501
+ scrapy/core/scraper.py E501 E306 E128 W504
+ scrapy/core/spidermw.py E501 E731 E126 E226
+ scrapy/core/downloader/__init__.py E501
+ scrapy/core/downloader/contextfactory.py E501 E128 E126
+ scrapy/core/downloader/middleware.py E501 E502
+ scrapy/core/downloader/tls.py E501 E305 E241
+ scrapy/core/downloader/webclient.py E731 E501 E128 E126 E226
+ scrapy/core/downloader/handlers/__init__.py E501
+ scrapy/core/downloader/handlers/ftp.py E501 E305 E128 E127
+ scrapy/core/downloader/handlers/http10.py E501
+ scrapy/core/downloader/handlers/http11.py E501
+ scrapy/core/downloader/handlers/s3.py E501 E128 E126
+ # scrapy/downloadermiddlewares
+ scrapy/downloadermiddlewares/ajaxcrawl.py E501 E226
+ scrapy/downloadermiddlewares/decompression.py E501
+ scrapy/downloadermiddlewares/defaultheaders.py E501
+ scrapy/downloadermiddlewares/httpcache.py E501 E126
+ scrapy/downloadermiddlewares/httpcompression.py E501 E128
+ scrapy/downloadermiddlewares/httpproxy.py E501
+ scrapy/downloadermiddlewares/redirect.py E501 W504
+ scrapy/downloadermiddlewares/retry.py E501 E126
+ scrapy/downloadermiddlewares/robotstxt.py E501
+ scrapy/downloadermiddlewares/stats.py E501
+ # scrapy/extensions
+ scrapy/extensions/closespider.py E501 E128 E123
+ scrapy/extensions/corestats.py E501
+ scrapy/extensions/feedexport.py E128 E501
+ scrapy/extensions/httpcache.py E128 E501 E303
+ scrapy/extensions/memdebug.py E501
+ scrapy/extensions/spiderstate.py E501
+ scrapy/extensions/telnet.py E501 W504
+ scrapy/extensions/throttle.py E501
+ # scrapy/http
+ scrapy/http/common.py E501
+ scrapy/http/cookies.py E501
+ scrapy/http/request/__init__.py E501
+ scrapy/http/request/form.py E501 E123
+ scrapy/http/request/json_request.py E501
+ scrapy/http/response/__init__.py E501 E128
+ scrapy/http/response/text.py E501 E128 E124
+ # scrapy/linkextractors
+ scrapy/linkextractors/__init__.py E731 E501 E402 W504
+ scrapy/linkextractors/lxmlhtml.py E501 E731 E226
+ # scrapy/loader
+ scrapy/loader/__init__.py E501 E128
+ scrapy/loader/processors.py E501
+ # scrapy/pipelines
+ scrapy/pipelines/__init__.py E501
+ scrapy/pipelines/files.py E116 E501 E266
+ scrapy/pipelines/images.py E265 E501
+ scrapy/pipelines/media.py E125 E501 E266
+ # scrapy/selector
+ scrapy/selector/__init__.py F403
+ scrapy/selector/unified.py E501 E111
+ # scrapy/settings
+ scrapy/settings/__init__.py E501
+ scrapy/settings/default_settings.py E501 E114 E116 E226
+ scrapy/settings/deprecated.py E501
+ # scrapy/spidermiddlewares
+ scrapy/spidermiddlewares/httperror.py E501
+ scrapy/spidermiddlewares/offsite.py E501
+ scrapy/spidermiddlewares/referer.py E501 E129 W503 W504
+ scrapy/spidermiddlewares/urllength.py E501
+ # scrapy/spiders
+ scrapy/spiders/__init__.py E501 E402
+ scrapy/spiders/crawl.py E501
+ scrapy/spiders/feed.py E501
+ scrapy/spiders/sitemap.py E501
+ # scrapy/utils
+ scrapy/utils/asyncio.py E501
+ scrapy/utils/benchserver.py E501
+ scrapy/utils/conf.py E402 E501
+ scrapy/utils/console.py E306 E305
+ scrapy/utils/datatypes.py E501 E226
+ scrapy/utils/decorators.py E501
+ scrapy/utils/defer.py E501 E128
+ scrapy/utils/deprecate.py E128 E501 E127 E502
+ scrapy/utils/gz.py E305 E501 W504
+ scrapy/utils/http.py F403 E226
+ scrapy/utils/httpobj.py E501
+ scrapy/utils/iterators.py E501 E701
+ scrapy/utils/log.py E128 W503
+ scrapy/utils/markup.py F403
+ scrapy/utils/misc.py E501 E226
+ scrapy/utils/multipart.py F403
+ scrapy/utils/project.py E501
+ scrapy/utils/python.py E501
+ scrapy/utils/reactor.py E226
+ scrapy/utils/reqser.py E501
+ scrapy/utils/request.py E127 E501
+ scrapy/utils/response.py E501 E128
+ scrapy/utils/signal.py E501 E128
+ scrapy/utils/sitemap.py E501
+ scrapy/utils/spider.py E271 E501
+ scrapy/utils/ssl.py E501
+ scrapy/utils/test.py E501
+ scrapy/utils/url.py E501 F403 E128 F405
+ # scrapy
+ scrapy/__init__.py E402 E501
+ scrapy/cmdline.py E501
+ scrapy/crawler.py E501
+ scrapy/dupefilters.py E501 E202
+ scrapy/exceptions.py E501
+ scrapy/exporters.py E501 E226
+ scrapy/interfaces.py E501
+ scrapy/item.py E501 E128
+ scrapy/link.py E501
+ scrapy/logformatter.py E501
+ scrapy/mail.py E402 E128 E501 E502
+ scrapy/middleware.py E128 E501
+ scrapy/pqueues.py E501
+ scrapy/resolver.py E501
+ scrapy/responsetypes.py E128 E501 E305
+ scrapy/robotstxt.py E501
+ scrapy/shell.py E501
+ scrapy/signalmanager.py E501
+ scrapy/spiderloader.py E225 F841 E501 E126
+ scrapy/squeues.py E128
+ scrapy/statscollectors.py E501
+ # tests
+ tests/__init__.py E402 E501
+ tests/mockserver.py E401 E501 E126 E123
+ tests/pipelines.py F841 E226
+ tests/spiders.py E501 E127
+ tests/test_closespider.py E501 E127
+ tests/test_command_fetch.py E501
+ tests/test_command_parse.py E501 E128 E303 E226
+ tests/test_command_shell.py E501 E128
+ tests/test_commands.py E128 E501
+ tests/test_contracts.py E501 E128
+ tests/test_crawl.py E501 E741 E265
+ tests/test_crawler.py F841 E306 E501
+ tests/test_dependencies.py F841 E501 E305
+ tests/test_downloader_handlers.py E124 E127 E128 E225 E265 E501 E701 E126 E226 E123
+ tests/test_downloadermiddleware.py E501
+ tests/test_downloadermiddleware_ajaxcrawlable.py E501
+ tests/test_downloadermiddleware_cookies.py E731 E741 E501 E128 E303 E265 E126
+ tests/test_downloadermiddleware_decompression.py E127
+ tests/test_downloadermiddleware_defaultheaders.py E501
+ tests/test_downloadermiddleware_downloadtimeout.py E501
+ tests/test_downloadermiddleware_httpcache.py E501 E305
+ tests/test_downloadermiddleware_httpcompression.py E501 E251 E126 E123
+ tests/test_downloadermiddleware_httpproxy.py E501 E128
+ tests/test_downloadermiddleware_redirect.py E501 E303 E128 E306 E127 E305
+ tests/test_downloadermiddleware_retry.py E501 E128 E251 E303 E126
+ tests/test_downloadermiddleware_robotstxt.py E501
+ tests/test_downloadermiddleware_stats.py E501
+ tests/test_dupefilters.py E221 E501 E741 E128 E124
+ tests/test_engine.py E401 E501 E128
+ tests/test_exporters.py E501 E731 E306 E128 E124
+ tests/test_extension_telnet.py F841
+ tests/test_feedexport.py E501 F841 E241
+ tests/test_http_cookies.py E501
+ tests/test_http_headers.py E501
+ tests/test_http_request.py E402 E501 E127 E128 E128 E126 E123
+ tests/test_http_response.py E501 E301 E128 E265
+ tests/test_item.py E701 E128 F841 E306
+ tests/test_link.py E501
+ tests/test_linkextractors.py E501 E128 E124
+ tests/test_loader.py E501 E731 E303 E741 E128 E117 E241
+ tests/test_logformatter.py E128 E501 E122
+ tests/test_mail.py E128 E501 E305
+ tests/test_middleware.py E501 E128
+ tests/test_pipeline_crawl.py E131 E501 E128 E126
+ tests/test_pipeline_files.py E501 E303 E272 E226
+ tests/test_pipeline_images.py F841 E501 E303
+ tests/test_pipeline_media.py E501 E741 E731 E128 E306 E502
+ tests/test_proxy_connect.py E501 E741
+ tests/test_request_cb_kwargs.py E501
+ tests/test_responsetypes.py E501 E305
+ tests/test_robotstxt_interface.py E501 E501
+ tests/test_scheduler.py E501 E126 E123
+ tests/test_selector.py E501 E127
+ tests/test_spider.py E501
+ tests/test_spidermiddleware.py E501 E226
+ tests/test_spidermiddleware_httperror.py E128 E501 E127 E121
+ tests/test_spidermiddleware_offsite.py E501 E128 E111
+ tests/test_spidermiddleware_output_chain.py E501 E226
+ tests/test_spidermiddleware_referer.py E501 F841 E125 E201 E124 E501 E241 E121
+ tests/test_squeues.py E501 E701 E741
+ tests/test_utils_asyncio.py E501
+ tests/test_utils_conf.py E501 E303 E128
+ tests/test_utils_curl.py E501
+ tests/test_utils_datatypes.py E402 E501 E305
+ tests/test_utils_defer.py E306 E501 F841 E226
+ tests/test_utils_deprecate.py F841 E306 E501
+ tests/test_utils_http.py E501 E128 W504
+ tests/test_utils_iterators.py E501 E128 E129 E303 E241
+ tests/test_utils_log.py E741 E226
+ tests/test_utils_python.py E501 E303 E731 E701 E305
+ tests/test_utils_reqser.py E501 E128
+ tests/test_utils_request.py E501 E128 E305
+ tests/test_utils_response.py E501
+ tests/test_utils_signal.py E741 F841 E731 E226
+ tests/test_utils_sitemap.py E128 E501 E124
+ tests/test_utils_spider.py E305
+ tests/test_utils_template.py E305
+ tests/test_utils_url.py E501 E127 E305 E211 E125 E501 E226 E241 E126 E123
+ tests/test_webclient.py E501 E128 E122 E303 E402 E306 E226 E241 E123 E126
+ tests/test_cmdline/__init__.py E501
+ tests/test_settings/__init__.py E501 E128
+ tests/test_spiderloader/__init__.py E128 E501
+ tests/test_utils_misc/__init__.py E501
diff --git a/requirements-py3.txt b/requirements-py3.txt
deleted file mode 100644
index 2aae3ae65..000000000
--- a/requirements-py3.txt
+++ /dev/null
@@ -1,7 +0,0 @@
-Twisted >= 17.9.0
-lxml>=3.2.4
-pyOpenSSL>=0.13.1
-cssselect>=0.9
-queuelib>=1.1.1
-w3lib>=1.17.0
-service_identity
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 2a94d742d..000000000
--- a/requirements.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-Twisted>=13.1.0
-lxml
-pyOpenSSL
-cssselect>=0.9
-w3lib>=1.17.0
-queuelib
-six>=1.5.2
-PyDispatcher>=2.0.5
-service_identity
-parsel>=1.4
diff --git a/scrapy/VERSION b/scrapy/VERSION
index bc80560fa..27f9cd322 100644
--- a/scrapy/VERSION
+++ b/scrapy/VERSION
@@ -1 +1 @@
-1.5.0
+1.8.0
diff --git a/scrapy/__init__.py b/scrapy/__init__.py
index 03ec6c667..fb8357f3c 100644
--- a/scrapy/__init__.py
+++ b/scrapy/__init__.py
@@ -14,8 +14,8 @@ del pkgutil
# Check minimum required Python version
import sys
-if sys.version_info < (2, 7):
- print("Scrapy %s requires Python 2.7" % __version__)
+if sys.version_info < (3, 5):
+ print("Scrapy %s requires Python 3.5" % __version__)
sys.exit(1)
# Ignore noisy twisted deprecation warnings
@@ -24,7 +24,7 @@ warnings.filterwarnings('ignore', category=DeprecationWarning, module='twisted')
del warnings
# Apply monkey patches to fix issues in external libraries
-from . import _monkeypatches
+from scrapy import _monkeypatches
del _monkeypatches
from twisted import version as _txv
diff --git a/scrapy/_monkeypatches.py b/scrapy/_monkeypatches.py
index f55ecc213..f74f89bda 100644
--- a/scrapy/_monkeypatches.py
+++ b/scrapy/_monkeypatches.py
@@ -1,18 +1,4 @@
-import sys
-from six.moves import copyreg
-
-if sys.version_info[0] == 2:
- from urlparse import urlparse
-
- # workaround for https://bugs.python.org/issue7904 - Python < 2.7
- if urlparse('s3://bucket/key').netloc != 'bucket':
- from urlparse import uses_netloc
- uses_netloc.append('s3')
-
- # workaround for https://bugs.python.org/issue9374 - Python < 2.7.4
- if urlparse('s3://bucket/key?key=value').query != 'key=value':
- from urlparse import uses_query
- uses_query.append('s3')
+import copyreg
# Undo what Twisted's perspective broker adds to pickle register
diff --git a/scrapy/cmdline.py b/scrapy/cmdline.py
index dc6b59fe0..ec78f7c91 100644
--- a/scrapy/cmdline.py
+++ b/scrapy/cmdline.py
@@ -1,5 +1,5 @@
-from __future__ import print_function
-import sys, os
+import sys
+import os
import optparse
import cProfile
import inspect
@@ -14,6 +14,7 @@ from scrapy.utils.project import inside_project, get_project_settings
from scrapy.utils.python import garbage_collect
from scrapy.settings.deprecated import check_deprecated_settings
+
def _iter_command_classes(module_name):
# TODO: add `name` attribute to commands and and merge this function with
# scrapy.utils.spider.iter_spider_classes
@@ -25,6 +26,7 @@ def _iter_command_classes(module_name):
not obj == ScrapyCommand:
yield obj
+
def _get_commands_from_module(module, inproject):
d = {}
for cmd in _iter_command_classes(module):
@@ -33,6 +35,7 @@ def _get_commands_from_module(module, inproject):
d[cmdname] = cmd()
return d
+
def _get_commands_from_entry_points(inproject, group='scrapy.commands'):
cmds = {}
for entry_point in pkg_resources.iter_entry_points(group):
@@ -43,6 +46,7 @@ def _get_commands_from_entry_points(inproject, group='scrapy.commands'):
raise Exception("Invalid entry point %s" % entry_point.name)
return cmds
+
def _get_commands_dict(settings, inproject):
cmds = _get_commands_from_module('scrapy.commands', inproject)
cmds.update(_get_commands_from_entry_points(inproject))
@@ -51,6 +55,7 @@ def _get_commands_dict(settings, inproject):
cmds.update(_get_commands_from_module(cmds_module, inproject))
return cmds
+
def _pop_command_name(argv):
i = 0
for arg in argv[1:]:
@@ -59,13 +64,15 @@ def _pop_command_name(argv):
return arg
i += 1
+
def _print_header(settings, inproject):
if inproject:
- print("Scrapy %s - project: %s\n" % (scrapy.__version__, \
- settings['BOT_NAME']))
+ print("Scrapy %s - project: %s\n" % (scrapy.__version__,
+ settings['BOT_NAME']))
else:
print("Scrapy %s - no active project\n" % scrapy.__version__)
+
def _print_commands(settings, inproject):
_print_header(settings, inproject)
print("Usage:")
@@ -80,11 +87,13 @@ def _print_commands(settings, inproject):
print()
print('Use "scrapy -h" to see more info about a command')
+
def _print_unknown_command(settings, cmdname, inproject):
_print_header(settings, inproject)
print("Unknown command: %s\n" % cmdname)
print('Use "scrapy" to see available commands')
+
def _run_print_help(parser, func, *a, **kw):
try:
func(*a, **kw)
@@ -95,41 +104,27 @@ def _run_print_help(parser, func, *a, **kw):
parser.print_help()
sys.exit(2)
+
def execute(argv=None, settings=None):
if argv is None:
argv = sys.argv
- # --- backwards compatibility for scrapy.conf.settings singleton ---
- if settings is None and 'scrapy.conf' in sys.modules:
- from scrapy import conf
- if hasattr(conf, 'settings'):
- settings = conf.settings
- # ------------------------------------------------------------------
-
if settings is None:
settings = get_project_settings()
# set EDITOR from environment if available
try:
editor = os.environ['EDITOR']
- except KeyError: pass
+ except KeyError:
+ pass
else:
settings['EDITOR'] = editor
check_deprecated_settings(settings)
- # --- backwards compatibility for scrapy.conf.settings singleton ---
- import warnings
- from scrapy.exceptions import ScrapyDeprecationWarning
- with warnings.catch_warnings():
- warnings.simplefilter("ignore", ScrapyDeprecationWarning)
- from scrapy import conf
- conf.settings = settings
- # ------------------------------------------------------------------
-
inproject = inside_project()
cmds = _get_commands_dict(settings, inproject)
cmdname = _pop_command_name(argv)
- parser = optparse.OptionParser(formatter=optparse.TitledHelpFormatter(), \
- conflict_handler='resolve')
+ parser = optparse.OptionParser(formatter=optparse.TitledHelpFormatter(),
+ conflict_handler='resolve')
if not cmdname:
_print_commands(settings, inproject)
sys.exit(0)
@@ -150,12 +145,14 @@ def execute(argv=None, settings=None):
_run_print_help(parser, _run_command, cmd, args, opts)
sys.exit(cmd.exitcode)
+
def _run_command(cmd, args, opts):
if opts.profile:
_run_command_profiled(cmd, args, opts)
else:
cmd.run(args, opts)
+
def _run_command_profiled(cmd, args, opts):
if opts.profile:
sys.stderr.write("scrapy: writing cProfile stats to %r\n" % opts.profile)
@@ -165,6 +162,7 @@ def _run_command_profiled(cmd, args, opts):
if opts.profile:
p.dump_stats(opts.profile)
+
if __name__ == '__main__':
try:
execute()
diff --git a/scrapy/command.py b/scrapy/command.py
deleted file mode 100644
index 3e1219bbc..000000000
--- a/scrapy/command.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.command` is deprecated, "
- "use `scrapy.commands` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.commands import *
diff --git a/scrapy/commands/__init__.py b/scrapy/commands/__init__.py
index 43b420821..0b24193c2 100644
--- a/scrapy/commands/__init__.py
+++ b/scrapy/commands/__init__.py
@@ -47,7 +47,7 @@ class ScrapyCommand(object):
def help(self):
"""An extensive help for the command. It will be shown when using the
- "help" command. It can contain newlines, since not post-formatting will
+ "help" command. It can contain newlines, since no post-formatting will
be applied to its contents.
"""
return self.long_desc()
diff --git a/scrapy/commands/bench.py b/scrapy/commands/bench.py
index 90c8d56a2..7bbe362e7 100644
--- a/scrapy/commands/bench.py
+++ b/scrapy/commands/bench.py
@@ -1,8 +1,7 @@
import sys
import time
import subprocess
-
-from six.moves.urllib.parse import urlencode
+from urllib.parse import urlencode
import scrapy
from scrapy.commands import ScrapyCommand
diff --git a/scrapy/commands/check.py b/scrapy/commands/check.py
index b8a9ef989..9d4437a47 100644
--- a/scrapy/commands/check.py
+++ b/scrapy/commands/check.py
@@ -1,12 +1,10 @@
-from __future__ import print_function
import time
-import sys
from collections import defaultdict
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 +66,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:
@@ -95,4 +94,3 @@ class Command(ScrapyCommand):
result.printErrors()
result.printSummary(start, stop)
self.exitcode = int(not result.wasSuccessful())
-
diff --git a/scrapy/commands/crawl.py b/scrapy/commands/crawl.py
index 4b986bf9d..8093fd402 100644
--- a/scrapy/commands/crawl.py
+++ b/scrapy/commands/crawl.py
@@ -56,3 +56,6 @@ class Command(ScrapyCommand):
self.crawler_process.crawl(spname, **opts.spargs)
self.crawler_process.start()
+
+ if self.crawler_process.bootstrap_failed:
+ self.exitcode = 1
diff --git a/scrapy/commands/fetch.py b/scrapy/commands/fetch.py
index 7d4840529..0e149941d 100644
--- a/scrapy/commands/fetch.py
+++ b/scrapy/commands/fetch.py
@@ -1,5 +1,4 @@
-from __future__ import print_function
-import sys, six
+import sys
from w3lib.url import is_url
from scrapy.commands import ScrapyCommand
@@ -8,6 +7,7 @@ from scrapy.exceptions import UsageError
from scrapy.utils.datatypes import SequenceExclude
from scrapy.utils.spider import spidercls_for_request, DefaultSpider
+
class Command(ScrapyCommand):
requires_project = False
@@ -24,12 +24,11 @@ class Command(ScrapyCommand):
def add_options(self, parser):
ScrapyCommand.add_options(self, parser)
- parser.add_option("--spider", dest="spider",
- help="use this spider")
- parser.add_option("--headers", dest="headers", action="store_true", \
- help="print response HTTP headers instead of body")
- parser.add_option("--no-redirect", dest="no_redirect", action="store_true", \
- default=False, help="do not handle HTTP 3xx status codes and print response as-is")
+ parser.add_option("--spider", dest="spider", help="use this spider")
+ parser.add_option("--headers", dest="headers", action="store_true",
+ help="print response HTTP headers instead of body")
+ parser.add_option("--no-redirect", dest="no_redirect", action="store_true",
+ default=False, help="do not handle HTTP 3xx status codes and print response as-is")
def _print_headers(self, headers, prefix):
for key, values in headers.items():
@@ -45,8 +44,7 @@ class Command(ScrapyCommand):
self._print_bytes(response.body)
def _print_bytes(self, bytes_):
- bytes_writer = sys.stdout if six.PY2 else sys.stdout.buffer
- bytes_writer.write(bytes_ + b'\n')
+ sys.stdout.buffer.write(bytes_ + b'\n')
def run(self, args, opts):
if len(args) != 1 or not is_url(args[0]):
diff --git a/scrapy/commands/genspider.py b/scrapy/commands/genspider.py
index d5498bb5c..adb01fa70 100644
--- a/scrapy/commands/genspider.py
+++ b/scrapy/commands/genspider.py
@@ -1,4 +1,3 @@
-from __future__ import print_function
import os
import shutil
import string
diff --git a/scrapy/commands/list.py b/scrapy/commands/list.py
index a255b3b94..54d7bb228 100644
--- a/scrapy/commands/list.py
+++ b/scrapy/commands/list.py
@@ -1,6 +1,6 @@
-from __future__ import print_function
from scrapy.commands import ScrapyCommand
+
class Command(ScrapyCommand):
requires_project = True
diff --git a/scrapy/commands/parse.py b/scrapy/commands/parse.py
index 69418a478..ff6f1d8cd 100644
--- a/scrapy/commands/parse.py
+++ b/scrapy/commands/parse.py
@@ -1,4 +1,3 @@
-from __future__ import print_function
import json
import logging
@@ -51,18 +50,21 @@ 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("--cbkwargs", dest="cbkwargs",
+ 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",
help="print each depth level one by one")
-
@property
def max_level(self):
- levels = list(self.items.keys()) + list(self.requests.keys())
- if not levels:
- return 0
- return max(levels)
+ max_items, max_requests = 0, 0
+ if self.items:
+ max_items = max(self.items)
+ if self.requests:
+ max_requests = max(self.requests)
+ return max(max_items, max_requests)
def add_items(self, lvl, new_items):
old_items = self.items.get(lvl, [])
@@ -83,9 +85,8 @@ class Command(ScrapyCommand):
def print_requests(self, lvl=None, colour=True):
if lvl is None:
- levels = list(self.requests.keys())
- if levels:
- requests = self.requests[max(levels)]
+ if self.requests:
+ requests = self.requests[max(self.requests)]
else:
requests = []
else:
@@ -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 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
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.cbkwargs:
+ try:
+ opts.cbkwargs = json.loads(opts.cbkwargs)
+ except ValueError:
+ 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/scrapy/commands/runspider.py b/scrapy/commands/runspider.py
index a98033dd1..57d8471ca 100644
--- a/scrapy/commands/runspider.py
+++ b/scrapy/commands/runspider.py
@@ -60,14 +60,13 @@ class Command(ScrapyCommand):
else:
self.settings.set('FEED_URI', opts.output, priority='cmdline')
feed_exporters = without_none_values(self.settings.getwithbase('FEED_EXPORTERS'))
- valid_output_formats = feed_exporters.keys()
if not opts.output_format:
opts.output_format = os.path.splitext(opts.output)[1].replace(".", "")
- if opts.output_format not in valid_output_formats:
+ if opts.output_format not in feed_exporters:
raise UsageError("Unrecognized output format '%s', set one"
" using the '-t' switch or as a file extension"
" from the supported list %s" % (opts.output_format,
- tuple(valid_output_formats)))
+ tuple(feed_exporters)))
self.settings.set('FEED_FORMAT', opts.output_format, priority='cmdline')
def run(self, args, opts):
@@ -87,3 +86,6 @@ class Command(ScrapyCommand):
self.crawler_process.crawl(spidercls, **opts.spargs)
self.crawler_process.start()
+
+ if self.crawler_process.bootstrap_failed:
+ self.exitcode = 1
diff --git a/scrapy/commands/settings.py b/scrapy/commands/settings.py
index bee52f06a..603bafb9f 100644
--- a/scrapy/commands/settings.py
+++ b/scrapy/commands/settings.py
@@ -1,9 +1,9 @@
-from __future__ import print_function
import json
from scrapy.commands import ScrapyCommand
from scrapy.settings import BaseSettings
+
class Command(ScrapyCommand):
requires_project = False
diff --git a/scrapy/commands/shell.py b/scrapy/commands/shell.py
index 40a58d94a..d44a32d5f 100644
--- a/scrapy/commands/shell.py
+++ b/scrapy/commands/shell.py
@@ -6,8 +6,8 @@ See documentation in docs/topics/shell.rst
from threading import Thread
from scrapy.commands import ScrapyCommand
-from scrapy.shell import Shell
from scrapy.http import Request
+from scrapy.shell import Shell
from scrapy.utils.spider import spidercls_for_request, DefaultSpider
from scrapy.utils.url import guess_scheme
@@ -28,7 +28,8 @@ class Command(ScrapyCommand):
return "Interactive scraping console"
def long_desc(self):
- return "Interactive console for scraping the given url"
+ return ("Interactive console for scraping the given url or file. "
+ "Use ./file.html syntax or full path for local file.")
def add_options(self, parser):
ScrapyCommand.add_options(self, parser)
diff --git a/scrapy/commands/startproject.py b/scrapy/commands/startproject.py
index c17aaf442..b123e5c84 100644
--- a/scrapy/commands/startproject.py
+++ b/scrapy/commands/startproject.py
@@ -1,4 +1,3 @@
-from __future__ import print_function
import re
import os
import string
@@ -44,8 +43,8 @@ class Command(ScrapyCommand):
return False
if not re.search(r'^[_a-zA-Z]\w*$', project_name):
- print('Error: Project names must begin with a letter and contain'\
- ' only\nletters, numbers and underscores')
+ print('Error: Project names must begin with a letter and contain'
+ ' only\nletters, numbers and underscores')
elif _module_exists(project_name):
print('Error: Module %r already exists' % project_name)
else:
@@ -107,8 +106,8 @@ class Command(ScrapyCommand):
string.Template(path).substitute(project_name=project_name))
render_templatefile(tplfile, project_name=project_name,
ProjectName=string_camelcase(project_name))
- print("New Scrapy project %r, using template directory %r, created in:" % \
- (project_name, self.templates_dir))
+ print("New Scrapy project '%s', using template directory '%s', "
+ "created in:" % (project_name, self.templates_dir))
print(" %s\n" % abspath(project_dir))
print("You can start your first spider with:")
print(" cd %s" % project_dir)
@@ -119,4 +118,3 @@ class Command(ScrapyCommand):
_templates_base_dir = self.settings['TEMPLATES_DIR'] or \
join(scrapy.__path__[0], 'templates')
return join(_templates_base_dir, 'project')
-
diff --git a/scrapy/commands/version.py b/scrapy/commands/version.py
index 577365c3b..1516c5997 100644
--- a/scrapy/commands/version.py
+++ b/scrapy/commands/version.py
@@ -1,5 +1,3 @@
-from __future__ import print_function
-
import scrapy
from scrapy.commands import ScrapyCommand
from scrapy.utils.versions import scrapy_components_versions
@@ -30,4 +28,3 @@ class Command(ScrapyCommand):
print(patt % (name, version))
else:
print("Scrapy %s" % scrapy.__version__)
-
diff --git a/scrapy/commands/view.py b/scrapy/commands/view.py
index 59e665016..41e77ba3b 100644
--- a/scrapy/commands/view.py
+++ b/scrapy/commands/view.py
@@ -1,6 +1,7 @@
-from scrapy.commands import fetch, ScrapyCommand
+from scrapy.commands import fetch
from scrapy.utils.response import open_in_browser
+
class Command(fetch.Command):
def short_desc(self):
diff --git a/scrapy/conf.py b/scrapy/conf.py
deleted file mode 100644
index 23efc6ffd..000000000
--- a/scrapy/conf.py
+++ /dev/null
@@ -1,13 +0,0 @@
-# This module is kept for backwards compatibility, so users can import
-# scrapy.conf.settings and get the settings they expect
-
-import sys
-
-if 'scrapy.cmdline' not in sys.modules:
- from scrapy.utils.project import get_project_settings
- settings = get_project_settings()
-
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.conf` is deprecated, use `crawler.settings` attribute instead",
- ScrapyDeprecationWarning, stacklevel=2)
diff --git a/scrapy/contracts/__init__.py b/scrapy/contracts/__init__.py
index 5eaee3d11..7b6591d86 100644
--- a/scrapy/contracts/__init__.py
+++ b/scrapy/contracts/__init__.py
@@ -1,6 +1,7 @@
import sys
import re
from functools import wraps
+from inspect import getmembers
from unittest import TestCase
from scrapy.http import Request
@@ -17,7 +18,7 @@ class ContractsManager(object):
def tested_methods_from_spidercls(self, spidercls):
methods = []
- for key, value in vars(spidercls).items():
+ for key, value in getmembers(spidercls):
if (callable(value) and value.__doc__ and
re.search(r'^\s*@', value.__doc__, re.MULTILINE)):
methods.append(key)
@@ -41,23 +42,38 @@ class ContractsManager(object):
requests = []
for method in self.tested_methods_from_spidercls(type(spider)):
bound_method = spider.__getattribute__(method)
- requests.append(self.from_method(bound_method, results))
+ try:
+ requests.append(self.from_method(bound_method, results))
+ except Exception:
+ case = _create_testcase(bound_method, 'contract')
+ results.addError(case, sys.exc_info())
return requests
def from_method(self, method, results):
contracts = self.extract_contracts(method)
if contracts:
+ request_cls = Request
+ for contract in contracts:
+ if contract.request_cls is not None:
+ request_cls = contract.request_cls
+
# calculate request args
- args, kwargs = get_spec(Request.__init__)
+ args, kwargs = get_spec(request_cls.__init__)
+
+ # Don't filter requests to allow
+ # testing different callbacks on the same URL.
+ kwargs['dont_filter'] = True
kwargs['callback'] = method
+
for contract in contracts:
kwargs = contract.adjust_request_args(kwargs)
- # create and prepare request
args.remove('self')
+
+ # check if all positional arguments are defined in kwargs
if set(args).issubset(set(kwargs)):
- request = Request(**kwargs)
+ request = request_cls(**kwargs)
# execute pre and post hooks in order
for contract in reversed(contracts):
@@ -74,17 +90,17 @@ class ContractsManager(object):
cb = request.callback
@wraps(cb)
- def cb_wrapper(response):
+ def cb_wrapper(response, **cb_kwargs):
try:
- output = cb(response)
+ output = cb(response, **cb_kwargs)
output = list(iterate_spider_output(output))
- except:
+ except Exception:
case = _create_testcase(method, 'callback')
results.addError(case, sys.exc_info())
def eb_wrapper(failure):
case = _create_testcase(method, 'errback')
- exc_info = failure.value, failure.type, failure.getTracebackObject()
+ exc_info = failure.type, failure.value, failure.getTracebackObject()
results.addError(case, exc_info)
request.callback = cb_wrapper
@@ -93,6 +109,7 @@ class ContractsManager(object):
class Contract(object):
""" Abstract class for contracts """
+ request_cls = None
def __init__(self, method, *args):
self.testcase_pre = _create_testcase(method, '@%s pre-hook' % self.name)
@@ -104,7 +121,7 @@ class Contract(object):
cb = request.callback
@wraps(cb)
- def wrapper(response):
+ def wrapper(response, **cb_kwargs):
try:
results.startTest(self.testcase_pre)
self.pre_process(response)
@@ -116,7 +133,7 @@ class Contract(object):
else:
results.addSuccess(self.testcase_pre)
finally:
- return list(iterate_spider_output(cb(response)))
+ return list(iterate_spider_output(cb(response, **cb_kwargs)))
request.callback = wrapper
@@ -127,8 +144,8 @@ class Contract(object):
cb = request.callback
@wraps(cb)
- def wrapper(response):
- output = list(iterate_spider_output(cb(response)))
+ def wrapper(response, **cb_kwargs):
+ output = list(iterate_spider_output(cb(response, **cb_kwargs)))
try:
results.startTest(self.testcase_post)
self.post_process(output)
diff --git a/scrapy/contracts/default.py b/scrapy/contracts/default.py
index 20582503d..3002fc702 100644
--- a/scrapy/contracts/default.py
+++ b/scrapy/contracts/default.py
@@ -1,8 +1,10 @@
+import json
+
from scrapy.item import BaseItem
from scrapy.http import Request
from scrapy.exceptions import ContractFail
-from . import Contract
+from scrapy.contracts import Contract
# contracts
@@ -18,6 +20,20 @@ class UrlContract(Contract):
return args
+class CallbackKeywordArgumentsContract(Contract):
+ """ Contract to set the keyword arguments for the request.
+ The value should be a JSON-encoded dictionary, e.g.:
+
+ @cb_kwargs {"arg1": "some value"}
+ """
+
+ name = 'cb_kwargs'
+
+ def adjust_request_args(self, args):
+ args['cb_kwargs'] = json.loads(' '.join(self.args))
+ return args
+
+
class ReturnsContract(Contract):
""" Contract to check the output of a callback
@@ -70,8 +86,8 @@ class ReturnsContract(Contract):
else:
expected = '%s..%s' % (self.min_bound, self.max_bound)
- raise ContractFail("Returned %s %s, expected %s" % \
- (occurrences, self.obj_name, expected))
+ raise ContractFail("Returned %s %s, expected %s" %
+ (occurrences, self.obj_name, expected))
class ScrapesContract(Contract):
@@ -84,6 +100,7 @@ class ScrapesContract(Contract):
def post_process(self, output):
for x in output:
if isinstance(x, (BaseItem, dict)):
- for arg in self.args:
- if not arg in x:
- raise ContractFail("'%s' field is missing" % arg)
+ missing = [arg for arg in self.args if arg not in x]
+ if missing:
+ raise ContractFail(
+ "Missing fields: %s" % ", ".join(missing))
diff --git a/scrapy/contrib/__init__.py b/scrapy/contrib/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/scrapy/contrib/closespider.py b/scrapy/contrib/closespider.py
deleted file mode 100644
index 9c52c418f..000000000
--- a/scrapy/contrib/closespider.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.closespider` is deprecated, "
- "use `scrapy.extensions.closespider` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.extensions.closespider import *
diff --git a/scrapy/contrib/corestats.py b/scrapy/contrib/corestats.py
deleted file mode 100644
index 2f5354239..000000000
--- a/scrapy/contrib/corestats.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.corestats` is deprecated, "
- "use `scrapy.extensions.corestats` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.extensions.corestats import *
diff --git a/scrapy/contrib/debug.py b/scrapy/contrib/debug.py
deleted file mode 100644
index a38f059ce..000000000
--- a/scrapy/contrib/debug.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.debug` is deprecated, "
- "use `scrapy.extensions.debug` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.extensions.debug import *
diff --git a/scrapy/contrib/downloadermiddleware/__init__.py b/scrapy/contrib/downloadermiddleware/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/scrapy/contrib/downloadermiddleware/ajaxcrawl.py b/scrapy/contrib/downloadermiddleware/ajaxcrawl.py
deleted file mode 100644
index 90ebc46b6..000000000
--- a/scrapy/contrib/downloadermiddleware/ajaxcrawl.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.downloadermiddleware.ajaxcrawl` is deprecated, "
- "use `scrapy.downloadermiddlewares.ajaxcrawl` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.downloadermiddlewares.ajaxcrawl import *
diff --git a/scrapy/contrib/downloadermiddleware/chunked.py b/scrapy/contrib/downloadermiddleware/chunked.py
deleted file mode 100644
index 1322c9083..000000000
--- a/scrapy/contrib/downloadermiddleware/chunked.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.downloadermiddleware.chunked` is deprecated, "
- "use `scrapy.downloadermiddlewares.chunked` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.downloadermiddlewares.chunked import *
diff --git a/scrapy/contrib/downloadermiddleware/cookies.py b/scrapy/contrib/downloadermiddleware/cookies.py
deleted file mode 100644
index bad970690..000000000
--- a/scrapy/contrib/downloadermiddleware/cookies.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.downloadermiddleware.cookies` is deprecated, "
- "use `scrapy.downloadermiddlewares.cookies` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.downloadermiddlewares.cookies import *
diff --git a/scrapy/contrib/downloadermiddleware/decompression.py b/scrapy/contrib/downloadermiddleware/decompression.py
deleted file mode 100644
index a541aa61e..000000000
--- a/scrapy/contrib/downloadermiddleware/decompression.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.downloadermiddleware.decompression` is deprecated, "
- "use `scrapy.downloadermiddlewares.decompression` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.downloadermiddlewares.decompression import *
diff --git a/scrapy/contrib/downloadermiddleware/defaultheaders.py b/scrapy/contrib/downloadermiddleware/defaultheaders.py
deleted file mode 100644
index cf023dc8f..000000000
--- a/scrapy/contrib/downloadermiddleware/defaultheaders.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.downloadermiddleware.defaultheaders` is deprecated, "
- "use `scrapy.downloadermiddlewares.defaultheaders` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.downloadermiddlewares.defaultheaders import *
diff --git a/scrapy/contrib/downloadermiddleware/downloadtimeout.py b/scrapy/contrib/downloadermiddleware/downloadtimeout.py
deleted file mode 100644
index 84bd06acf..000000000
--- a/scrapy/contrib/downloadermiddleware/downloadtimeout.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.downloadermiddleware.downloadtimeout` is deprecated, "
- "use `scrapy.downloadermiddlewares.downloadtimeout` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.downloadermiddlewares.downloadtimeout import *
diff --git a/scrapy/contrib/downloadermiddleware/httpauth.py b/scrapy/contrib/downloadermiddleware/httpauth.py
deleted file mode 100644
index a37ffa0dc..000000000
--- a/scrapy/contrib/downloadermiddleware/httpauth.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.downloadermiddleware.httpauth` is deprecated, "
- "use `scrapy.downloadermiddlewares.httpauth` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.downloadermiddlewares.httpauth import *
diff --git a/scrapy/contrib/downloadermiddleware/httpcache.py b/scrapy/contrib/downloadermiddleware/httpcache.py
deleted file mode 100644
index f5f068204..000000000
--- a/scrapy/contrib/downloadermiddleware/httpcache.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.downloadermiddleware.httpcache` is deprecated, "
- "use `scrapy.downloadermiddlewares.httpcache` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.downloadermiddlewares.httpcache import *
diff --git a/scrapy/contrib/downloadermiddleware/httpcompression.py b/scrapy/contrib/downloadermiddleware/httpcompression.py
deleted file mode 100644
index 8a52ec50b..000000000
--- a/scrapy/contrib/downloadermiddleware/httpcompression.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.downloadermiddleware.httpcompression` is deprecated, "
- "use `scrapy.downloadermiddlewares.httpcompression` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.downloadermiddlewares.httpcompression import *
diff --git a/scrapy/contrib/downloadermiddleware/httpproxy.py b/scrapy/contrib/downloadermiddleware/httpproxy.py
deleted file mode 100644
index d94d85076..000000000
--- a/scrapy/contrib/downloadermiddleware/httpproxy.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.downloadermiddleware.httpproxy` is deprecated, "
- "use `scrapy.downloadermiddlewares.httpproxy` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.downloadermiddlewares.httpproxy import *
diff --git a/scrapy/contrib/downloadermiddleware/redirect.py b/scrapy/contrib/downloadermiddleware/redirect.py
deleted file mode 100644
index 824eee8ae..000000000
--- a/scrapy/contrib/downloadermiddleware/redirect.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.downloadermiddleware.redirect` is deprecated, "
- "use `scrapy.downloadermiddlewares.redirect` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.downloadermiddlewares.redirect import *
diff --git a/scrapy/contrib/downloadermiddleware/retry.py b/scrapy/contrib/downloadermiddleware/retry.py
deleted file mode 100644
index aafe0f508..000000000
--- a/scrapy/contrib/downloadermiddleware/retry.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.downloadermiddleware.retry` is deprecated, "
- "use `scrapy.downloadermiddlewares.retry` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.downloadermiddlewares.retry import *
diff --git a/scrapy/contrib/downloadermiddleware/robotstxt.py b/scrapy/contrib/downloadermiddleware/robotstxt.py
deleted file mode 100644
index 408f760a0..000000000
--- a/scrapy/contrib/downloadermiddleware/robotstxt.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.downloadermiddleware.robotstxt` is deprecated, "
- "use `scrapy.downloadermiddlewares.robotstxt` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.downloadermiddlewares.robotstxt import *
diff --git a/scrapy/contrib/downloadermiddleware/stats.py b/scrapy/contrib/downloadermiddleware/stats.py
deleted file mode 100644
index fa84a8206..000000000
--- a/scrapy/contrib/downloadermiddleware/stats.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.downloadermiddleware.stats` is deprecated, "
- "use `scrapy.downloadermiddlewares.stats` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.downloadermiddlewares.stats import *
diff --git a/scrapy/contrib/downloadermiddleware/useragent.py b/scrapy/contrib/downloadermiddleware/useragent.py
deleted file mode 100644
index 893d5241c..000000000
--- a/scrapy/contrib/downloadermiddleware/useragent.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.downloadermiddleware.useragent` is deprecated, "
- "use `scrapy.downloadermiddlewares.useragent` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.downloadermiddlewares.useragent import *
diff --git a/scrapy/contrib/exporter/__init__.py b/scrapy/contrib/exporter/__init__.py
deleted file mode 100644
index 12adaaddd..000000000
--- a/scrapy/contrib/exporter/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.exporter` is deprecated, "
- "use `scrapy.exporters` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.exporters import *
-from scrapy.exporters import PythonItemExporter
diff --git a/scrapy/contrib/feedexport.py b/scrapy/contrib/feedexport.py
deleted file mode 100644
index 19651998a..000000000
--- a/scrapy/contrib/feedexport.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.feedexport` is deprecated, "
- "use `scrapy.extensions.feedexport` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.extensions.feedexport import *
diff --git a/scrapy/contrib/httpcache.py b/scrapy/contrib/httpcache.py
deleted file mode 100644
index 196372fcb..000000000
--- a/scrapy/contrib/httpcache.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.httpcache` is deprecated, "
- "use `scrapy.extensions.httpcache` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.extensions.httpcache import *
diff --git a/scrapy/contrib/linkextractors/__init__.py b/scrapy/contrib/linkextractors/__init__.py
deleted file mode 100644
index 976658df3..000000000
--- a/scrapy/contrib/linkextractors/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.linkextractors` is deprecated, "
- "use `scrapy.linkextractors` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.linkextractors import *
diff --git a/scrapy/contrib/linkextractors/htmlparser.py b/scrapy/contrib/linkextractors/htmlparser.py
deleted file mode 100644
index ff03da98f..000000000
--- a/scrapy/contrib/linkextractors/htmlparser.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.linkextractors.htmlparser` is deprecated, "
- "use `scrapy.linkextractors.htmlparser` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.linkextractors.htmlparser import *
diff --git a/scrapy/contrib/linkextractors/lxmlhtml.py b/scrapy/contrib/linkextractors/lxmlhtml.py
deleted file mode 100644
index fc2b7de3c..000000000
--- a/scrapy/contrib/linkextractors/lxmlhtml.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.linkextractors.lxmlhtml` is deprecated, "
- "use `scrapy.linkextractors.lxmlhtml` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.linkextractors.lxmlhtml import *
diff --git a/scrapy/contrib/linkextractors/regex.py b/scrapy/contrib/linkextractors/regex.py
deleted file mode 100644
index 97bda29c1..000000000
--- a/scrapy/contrib/linkextractors/regex.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.linkextractors.regex` is deprecated, "
- "use `scrapy.linkextractors.regex` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.linkextractors.regex import *
diff --git a/scrapy/contrib/linkextractors/sgml.py b/scrapy/contrib/linkextractors/sgml.py
deleted file mode 100644
index a5a598208..000000000
--- a/scrapy/contrib/linkextractors/sgml.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.linkextractors.sgml` is deprecated, "
- "use `scrapy.linkextractors.sgml` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.linkextractors.sgml import *
diff --git a/scrapy/contrib/loader/__init__.py b/scrapy/contrib/loader/__init__.py
deleted file mode 100644
index 2b9453e18..000000000
--- a/scrapy/contrib/loader/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.loader` is deprecated, "
- "use `scrapy.loader` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.loader import *
diff --git a/scrapy/contrib/loader/common.py b/scrapy/contrib/loader/common.py
deleted file mode 100644
index a59b2b7b1..000000000
--- a/scrapy/contrib/loader/common.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.loader.common` is deprecated, "
- "use `scrapy.loader.common` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.loader.common import *
diff --git a/scrapy/contrib/loader/processor.py b/scrapy/contrib/loader/processor.py
deleted file mode 100644
index da7e484a5..000000000
--- a/scrapy/contrib/loader/processor.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.loader.processor` is deprecated, "
- "use `scrapy.loader.processors` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.loader.processors import *
diff --git a/scrapy/contrib/logstats.py b/scrapy/contrib/logstats.py
deleted file mode 100644
index 62bc9b860..000000000
--- a/scrapy/contrib/logstats.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.logstats` is deprecated, "
- "use `scrapy.extensions.logstats` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.extensions.logstats import *
diff --git a/scrapy/contrib/memdebug.py b/scrapy/contrib/memdebug.py
deleted file mode 100644
index 4f6e4760e..000000000
--- a/scrapy/contrib/memdebug.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.memdebug` is deprecated, "
- "use `scrapy.extensions.memdebug` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.extensions.memdebug import *
diff --git a/scrapy/contrib/memusage.py b/scrapy/contrib/memusage.py
deleted file mode 100644
index e13bd78f3..000000000
--- a/scrapy/contrib/memusage.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.memusage` is deprecated, "
- "use `scrapy.extensions.memusage` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.extensions.memusage import *
diff --git a/scrapy/contrib/pipeline/__init__.py b/scrapy/contrib/pipeline/__init__.py
deleted file mode 100644
index aedf34a3f..000000000
--- a/scrapy/contrib/pipeline/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.pipeline` is deprecated, "
- "use `scrapy.pipelines` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.pipelines import *
diff --git a/scrapy/contrib/pipeline/files.py b/scrapy/contrib/pipeline/files.py
deleted file mode 100644
index cd1238b5d..000000000
--- a/scrapy/contrib/pipeline/files.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.pipeline.files` is deprecated, "
- "use `scrapy.pipelines.files` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.pipelines.files import *
diff --git a/scrapy/contrib/pipeline/images.py b/scrapy/contrib/pipeline/images.py
deleted file mode 100644
index 4f5ce4c40..000000000
--- a/scrapy/contrib/pipeline/images.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.pipeline.images` is deprecated, "
- "use `scrapy.pipelines.images` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.pipelines.images import *
diff --git a/scrapy/contrib/pipeline/media.py b/scrapy/contrib/pipeline/media.py
deleted file mode 100644
index 4b4fea560..000000000
--- a/scrapy/contrib/pipeline/media.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.pipeline.media` is deprecated, "
- "use `scrapy.pipelines.media` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.pipelines.media import *
diff --git a/scrapy/contrib/spidermiddleware/__init__.py b/scrapy/contrib/spidermiddleware/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/scrapy/contrib/spidermiddleware/depth.py b/scrapy/contrib/spidermiddleware/depth.py
deleted file mode 100644
index 718803148..000000000
--- a/scrapy/contrib/spidermiddleware/depth.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.spidermiddleware.depth` is deprecated, "
- "use `scrapy.spidermiddlewares.depth` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.spidermiddlewares.depth import *
diff --git a/scrapy/contrib/spidermiddleware/httperror.py b/scrapy/contrib/spidermiddleware/httperror.py
deleted file mode 100644
index e39fb3f56..000000000
--- a/scrapy/contrib/spidermiddleware/httperror.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.spidermiddleware.httperror` is deprecated, "
- "use `scrapy.spidermiddlewares.httperror` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.spidermiddlewares.httperror import *
diff --git a/scrapy/contrib/spidermiddleware/offsite.py b/scrapy/contrib/spidermiddleware/offsite.py
deleted file mode 100644
index a5ed9ea7e..000000000
--- a/scrapy/contrib/spidermiddleware/offsite.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.spidermiddleware.offsite` is deprecated, "
- "use `scrapy.spidermiddlewares.offsite` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.spidermiddlewares.offsite import *
diff --git a/scrapy/contrib/spidermiddleware/referer.py b/scrapy/contrib/spidermiddleware/referer.py
deleted file mode 100644
index fdf8d6659..000000000
--- a/scrapy/contrib/spidermiddleware/referer.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.spidermiddleware.referer` is deprecated, "
- "use `scrapy.spidermiddlewares.referer` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.spidermiddlewares.referer import *
diff --git a/scrapy/contrib/spidermiddleware/urllength.py b/scrapy/contrib/spidermiddleware/urllength.py
deleted file mode 100644
index 5e51add59..000000000
--- a/scrapy/contrib/spidermiddleware/urllength.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.spidermiddleware.urllength` is deprecated, "
- "use `scrapy.spidermiddlewares.urllength` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.spidermiddlewares.urllength import *
diff --git a/scrapy/contrib/spiders/__init__.py b/scrapy/contrib/spiders/__init__.py
deleted file mode 100644
index 56780533b..000000000
--- a/scrapy/contrib/spiders/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.spiders` is deprecated, "
- "use `scrapy.spiders` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.spiders import *
diff --git a/scrapy/contrib/spiders/crawl.py b/scrapy/contrib/spiders/crawl.py
deleted file mode 100644
index d20a8bb16..000000000
--- a/scrapy/contrib/spiders/crawl.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.spiders.crawl` is deprecated, "
- "use `scrapy.spiders.crawl` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.spiders.crawl import *
diff --git a/scrapy/contrib/spiders/feed.py b/scrapy/contrib/spiders/feed.py
deleted file mode 100644
index 5eea9a062..000000000
--- a/scrapy/contrib/spiders/feed.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.spiders.feed` is deprecated, "
- "use `scrapy.spiders.feed` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.spiders.feed import *
diff --git a/scrapy/contrib/spiders/init.py b/scrapy/contrib/spiders/init.py
deleted file mode 100644
index 6d1ec0aa9..000000000
--- a/scrapy/contrib/spiders/init.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.spiders.init` is deprecated, "
- "use `scrapy.spiders.init` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.spiders.init import *
diff --git a/scrapy/contrib/spiders/sitemap.py b/scrapy/contrib/spiders/sitemap.py
deleted file mode 100644
index 2ad231fd8..000000000
--- a/scrapy/contrib/spiders/sitemap.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.spiders.sitemap` is deprecated, "
- "use `scrapy.spiders.sitemap` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.spiders.sitemap import *
diff --git a/scrapy/contrib/spiderstate.py b/scrapy/contrib/spiderstate.py
deleted file mode 100644
index 06afc8bfc..000000000
--- a/scrapy/contrib/spiderstate.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.spiderstate` is deprecated, "
- "use `scrapy.extensions.spiderstate` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.extensions.spiderstate import *
diff --git a/scrapy/contrib/statsmailer.py b/scrapy/contrib/statsmailer.py
deleted file mode 100644
index f9c9a37f5..000000000
--- a/scrapy/contrib/statsmailer.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.statsmailer` is deprecated, "
- "use `scrapy.extensions.statsmailer` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.extensions.statsmailer import *
diff --git a/scrapy/contrib/throttle.py b/scrapy/contrib/throttle.py
deleted file mode 100644
index d5c234871..000000000
--- a/scrapy/contrib/throttle.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib.throttle` is deprecated, "
- "use `scrapy.extensions.throttle` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.extensions.throttle import *
diff --git a/scrapy/contrib_exp/__init__.py b/scrapy/contrib_exp/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/scrapy/contrib_exp/downloadermiddleware/__init__.py b/scrapy/contrib_exp/downloadermiddleware/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/scrapy/contrib_exp/downloadermiddleware/decompression.py b/scrapy/contrib_exp/downloadermiddleware/decompression.py
deleted file mode 100644
index 1f8490587..000000000
--- a/scrapy/contrib_exp/downloadermiddleware/decompression.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib_exp.downloadermiddleware.decompression` is deprecated, "
- "use `scrapy.downloadermiddlewares.decompression` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.downloadermiddlewares.decompression import DecompressionMiddleware
diff --git a/scrapy/contrib_exp/iterators.py b/scrapy/contrib_exp/iterators.py
deleted file mode 100644
index c59f47bcc..000000000
--- a/scrapy/contrib_exp/iterators.py
+++ /dev/null
@@ -1,6 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.contrib_exp.iterators` is deprecated, use `scrapy.utils.iterators` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.utils.iterators import xmliter_lxml
diff --git a/scrapy/core/downloader/__init__.py b/scrapy/core/downloader/__init__.py
index d835e65f7..157dc3418 100644
--- a/scrapy/core/downloader/__init__.py
+++ b/scrapy/core/downloader/__init__.py
@@ -1,19 +1,16 @@
-from __future__ import absolute_import
import random
-import warnings
from time import time
from datetime import datetime
from collections import deque
-import six
from twisted.internet import reactor, defer, task
from scrapy.utils.defer import mustbe_deferred
from scrapy.utils.httpobj import urlparse_cached
from scrapy.resolver import dnscache
from scrapy import signals
-from .middleware import DownloaderMiddlewareManager
-from .handlers import DownloadHandlers
+from scrapy.core.downloader.middleware import DownloaderMiddlewareManager
+from scrapy.core.downloader.handlers import DownloadHandlers
class Slot(object):
@@ -60,10 +57,6 @@ class Slot(object):
def _get_concurrency_delay(concurrency, spider, settings):
delay = settings.getfloat('DOWNLOAD_DELAY')
- if hasattr(spider, 'DOWNLOAD_DELAY'):
- warnings.warn("%s.DOWNLOAD_DELAY attribute is deprecated, use %s.download_delay instead" %
- (type(spider).__name__, type(spider).__name__))
- delay = spider.DOWNLOAD_DELAY
if hasattr(spider, 'download_delay'):
delay = spider.download_delay
@@ -75,6 +68,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 +106,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,13 +117,16 @@ 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)
return response
slot.active.add(request)
+ self.signals.send_catch_log(signal=signals.request_reached_downloader,
+ request=request,
+ spider=spider)
deferred = defer.Deferred().addBoth(_deactivate)
slot.queue.append((request, deferred))
self._process_queue(spider, slot)
@@ -189,7 +187,7 @@ class Downloader(object):
def close(self):
self._slot_gc_loop.stop()
- for slot in six.itervalues(self.slots):
+ for slot in self.slots.values():
slot.close()
def _slot_gc(self, age=60):
diff --git a/scrapy/core/downloader/contextfactory.py b/scrapy/core/downloader/contextfactory.py
index 783d4c383..6e023ebcc 100644
--- a/scrapy/core/downloader/contextfactory.py
+++ b/scrapy/core/downloader/contextfactory.py
@@ -1,105 +1,93 @@
from OpenSSL import SSL
-from twisted.internet.ssl import ClientContextFactory
+from twisted.internet.ssl import optionsForClientTLS, CertificateOptions, platformTrust, AcceptableCiphers
+from twisted.web.client import BrowserLikePolicyForHTTPS
+from twisted.web.iweb import IPolicyForHTTPS
+from zope.interface.declarations import implementer
-from scrapy import twisted_version
-
-if twisted_version >= (14, 0, 0):
-
- from zope.interface.declarations import implementer
-
- from twisted.internet.ssl import (optionsForClientTLS,
- CertificateOptions,
- platformTrust)
- from twisted.web.client import BrowserLikePolicyForHTTPS
- from twisted.web.iweb import IPolicyForHTTPS
-
- from scrapy.core.downloader.tls import ScrapyClientTLSOptions, DEFAULT_CIPHERS
+from scrapy.core.downloader.tls import ScrapyClientTLSOptions, DEFAULT_CIPHERS
- @implementer(IPolicyForHTTPS)
- class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS):
- """
- Non-peer-certificate verifying HTTPS context factory
+@implementer(IPolicyForHTTPS)
+class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS):
+ """
+ Non-peer-certificate verifying HTTPS context factory
- Default OpenSSL method is TLS_METHOD (also called SSLv23_METHOD)
- which allows TLS protocol negotiation
+ Default OpenSSL method is TLS_METHOD (also called SSLv23_METHOD)
+ which allows TLS protocol negotiation
- 'A TLS/SSL connection established with [this method] may
- understand the SSLv3, TLSv1, TLSv1.1 and TLSv1.2 protocols.'
- """
+ 'A TLS/SSL connection established with [this method] may
+ understand the SSLv3, TLSv1, TLSv1.1 and TLSv1.2 protocols.'
+ """
- def __init__(self, method=SSL.SSLv23_METHOD, *args, **kwargs):
- super(ScrapyClientContextFactory, self).__init__(*args, **kwargs)
- self._ssl_method = method
+ def __init__(self, method=SSL.SSLv23_METHOD, tls_verbose_logging=False, tls_ciphers=None, *args, **kwargs):
+ super(ScrapyClientContextFactory, self).__init__(*args, **kwargs)
+ self._ssl_method = method
+ self.tls_verbose_logging = tls_verbose_logging
+ if tls_ciphers:
+ self.tls_ciphers = AcceptableCiphers.fromOpenSSLCipherString(tls_ciphers)
+ else:
+ self.tls_ciphers = DEFAULT_CIPHERS
- def getCertificateOptions(self):
- # setting verify=True will require you to provide CAs
- # to verify against; in other words: it's not that simple
+ @classmethod
+ def from_settings(cls, settings, method=SSL.SSLv23_METHOD, *args, **kwargs):
+ tls_verbose_logging = settings.getbool('DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING')
+ tls_ciphers = settings['DOWNLOADER_CLIENT_TLS_CIPHERS']
+ return cls(method=method, tls_verbose_logging=tls_verbose_logging, tls_ciphers=tls_ciphers, *args, **kwargs)
- # backward-compatible SSL/TLS method:
- #
- # * this will respect `method` attribute in often recommended
- # `ScrapyClientContextFactory` subclass
- # (https://github.com/scrapy/scrapy/issues/1429#issuecomment-131782133)
- #
- # * getattr() for `_ssl_method` attribute for context factories
- # not calling super(..., self).__init__
- return CertificateOptions(verify=False,
- method=getattr(self, 'method',
- getattr(self, '_ssl_method', None)),
- fixBrokenPeers=True,
- acceptableCiphers=DEFAULT_CIPHERS)
+ def getCertificateOptions(self):
+ # setting verify=True will require you to provide CAs
+ # to verify against; in other words: it's not that simple
- # kept for old-style HTTP/1.0 downloader context twisted calls,
- # e.g. connectSSL()
- def getContext(self, hostname=None, port=None):
- return self.getCertificateOptions().getContext()
+ # backward-compatible SSL/TLS method:
+ #
+ # * this will respect `method` attribute in often recommended
+ # `ScrapyClientContextFactory` subclass
+ # (https://github.com/scrapy/scrapy/issues/1429#issuecomment-131782133)
+ #
+ # * getattr() for `_ssl_method` attribute for context factories
+ # not calling super(..., self).__init__
+ return CertificateOptions(verify=False,
+ method=getattr(self, 'method',
+ getattr(self, '_ssl_method', None)),
+ fixBrokenPeers=True,
+ acceptableCiphers=self.tls_ciphers)
- def creatorForNetloc(self, hostname, port):
- return ScrapyClientTLSOptions(hostname.decode("ascii"), self.getContext())
+ # kept for old-style HTTP/1.0 downloader context twisted calls,
+ # e.g. connectSSL()
+ def getContext(self, hostname=None, port=None):
+ return self.getCertificateOptions().getContext()
+
+ def creatorForNetloc(self, hostname, port):
+ return ScrapyClientTLSOptions(hostname.decode("ascii"), self.getContext(),
+ verbose_logging=self.tls_verbose_logging)
- @implementer(IPolicyForHTTPS)
- class BrowserLikeContextFactory(ScrapyClientContextFactory):
- """
- Twisted-recommended context factory for web clients.
+@implementer(IPolicyForHTTPS)
+class BrowserLikeContextFactory(ScrapyClientContextFactory):
+ """
+ Twisted-recommended context factory for web clients.
- Quoting https://twistedmatrix.com/documents/current/api/twisted.web.client.Agent.html:
- "The default is to use a BrowserLikePolicyForHTTPS,
- so unless you have special requirements you can leave this as-is."
+ Quoting the documentation of the :class:`~twisted.web.client.Agent` class:
- creatorForNetloc() is the same as BrowserLikePolicyForHTTPS
- except this context factory allows setting the TLS/SSL method to use.
+ The default is to use a
+ :class:`~twisted.web.client.BrowserLikePolicyForHTTPS`, so unless you
+ have special requirements you can leave this as-is.
- Default OpenSSL method is TLS_METHOD (also called SSLv23_METHOD)
- which allows TLS protocol negotiation.
- """
- def creatorForNetloc(self, hostname, port):
+ :meth:`creatorForNetloc` is the same as
+ :class:`~twisted.web.client.BrowserLikePolicyForHTTPS` except this context
+ factory allows setting the TLS/SSL method to use.
- # trustRoot set to platformTrust() will use the platform's root CAs.
- #
- # This means that a website like https://www.cacert.org will be rejected
- # by default, since CAcert.org CA certificate is seldom shipped.
- return optionsForClientTLS(hostname.decode("ascii"),
- trustRoot=platformTrust(),
- extraCertificateOptions={
- 'method': self._ssl_method,
- })
+ The default OpenSSL method is ``TLS_METHOD`` (also called
+ ``SSLv23_METHOD``) which allows TLS protocol negotiation.
+ """
+ def creatorForNetloc(self, hostname, port):
-else:
-
- class ScrapyClientContextFactory(ClientContextFactory):
- "A SSL context factory which is more permissive against SSL bugs."
- # see https://github.com/scrapy/scrapy/issues/82
- # and https://github.com/scrapy/scrapy/issues/26
- # and https://github.com/scrapy/scrapy/issues/981
-
- def __init__(self, method=SSL.SSLv23_METHOD):
- self.method = method
-
- def getContext(self, hostname=None, port=None):
- ctx = ClientContextFactory.getContext(self)
- # Enable all workarounds to SSL bugs as documented by
- # https://www.openssl.org/docs/manmaster/man3/SSL_CTX_set_options.html
- ctx.set_options(SSL.OP_ALL)
- return ctx
+ # trustRoot set to platformTrust() will use the platform's root CAs.
+ #
+ # This means that a website like https://www.cacert.org will be rejected
+ # by default, since CAcert.org CA certificate is seldom shipped.
+ return optionsForClientTLS(hostname.decode("ascii"),
+ trustRoot=platformTrust(),
+ extraCertificateOptions={
+ 'method': self._ssl_method,
+ })
diff --git a/scrapy/core/downloader/handlers/__init__.py b/scrapy/core/downloader/handlers/__init__.py
index bc5cd742e..e86680978 100644
--- a/scrapy/core/downloader/handlers/__init__.py
+++ b/scrapy/core/downloader/handlers/__init__.py
@@ -1,19 +1,20 @@
"""Download handlers for different schemes"""
import logging
+
from twisted.internet import defer
-import six
-from scrapy.exceptions import NotSupported, NotConfigured
-from scrapy.utils.httpobj import urlparse_cached
-from scrapy.utils.misc import load_object
-from scrapy.utils.python import without_none_values
+
from scrapy import signals
+from scrapy.exceptions import NotConfigured, NotSupported
+from scrapy.utils.httpobj import urlparse_cached
+from scrapy.utils.misc import create_instance, load_object
+from scrapy.utils.python import without_none_values
logger = logging.getLogger(__name__)
-class DownloadHandlers(object):
+class DownloadHandlers:
def __init__(self, crawler):
self._crawler = crawler
@@ -22,8 +23,9 @@ class DownloadHandlers(object):
self._notconfigured = {} # remembers failed handlers
handlers = without_none_values(
crawler.settings.getwithbase('DOWNLOAD_HANDLERS'))
- for scheme, clspath in six.iteritems(handlers):
+ for scheme, clspath in handlers.items():
self._schemes[scheme] = clspath
+ self._load_handler(scheme, skip_lazy=True)
crawler.signals.connect(self._close, signals.engine_stopped)
@@ -39,22 +41,31 @@ class DownloadHandlers(object):
self._notconfigured[scheme] = 'no handler available for that scheme'
return None
+ return self._load_handler(scheme)
+
+ def _load_handler(self, scheme, skip_lazy=False):
path = self._schemes[scheme]
try:
dhcls = load_object(path)
- dh = dhcls(self._crawler.settings)
+ if skip_lazy and getattr(dhcls, 'lazy', True):
+ return None
+ dh = create_instance(
+ objcls=dhcls,
+ settings=self._crawler.settings,
+ crawler=self._crawler,
+ )
except NotConfigured as ex:
self._notconfigured[scheme] = str(ex)
return None
except Exception as ex:
logger.error('Loading "%(clspath)s" for scheme "%(scheme)s"',
{"clspath": path, "scheme": scheme},
- exc_info=True, extra={'crawler': self._crawler})
+ exc_info=True, extra={'crawler': self._crawler})
self._notconfigured[scheme] = str(ex)
return None
else:
self._handlers[scheme] = dh
- return self._handlers[scheme]
+ return dh
def download_request(self, request, spider):
scheme = urlparse_cached(request).scheme
diff --git a/scrapy/core/downloader/handlers/datauri.py b/scrapy/core/downloader/handlers/datauri.py
index d102f2b73..a45b4ff3c 100644
--- a/scrapy/core/downloader/handlers/datauri.py
+++ b/scrapy/core/downloader/handlers/datauri.py
@@ -5,9 +5,8 @@ from scrapy.responsetypes import responsetypes
from scrapy.utils.decorators import defers
-class DataURIDownloadHandler(object):
- def __init__(self, settings):
- super(DataURIDownloadHandler, self).__init__()
+class DataURIDownloadHandler:
+ lazy = False
@defers
def download_request(self, request, spider):
@@ -15,8 +14,8 @@ class DataURIDownloadHandler(object):
respcls = responsetypes.from_mimetype(uri.media_type)
resp_kwargs = {}
- if (issubclass(respcls, TextResponse) and
- uri.media_type.split('/')[0] == 'text'):
+ if (issubclass(respcls, TextResponse)
+ and uri.media_type.split('/')[0] == 'text'):
charset = uri.media_type_parameters.get('charset')
resp_kwargs['encoding'] = charset
diff --git a/scrapy/core/downloader/handlers/file.py b/scrapy/core/downloader/handlers/file.py
index 9346ce08d..0d94e3df0 100644
--- a/scrapy/core/downloader/handlers/file.py
+++ b/scrapy/core/downloader/handlers/file.py
@@ -1,11 +1,11 @@
from w3lib.url import file_uri_to_path
+
from scrapy.responsetypes import responsetypes
from scrapy.utils.decorators import defers
-class FileDownloadHandler(object):
- def __init__(self, settings):
- pass
+class FileDownloadHandler:
+ lazy = False
@defers
def download_request(self, request, spider):
diff --git a/scrapy/core/downloader/handlers/ftp.py b/scrapy/core/downloader/handlers/ftp.py
index 933bc7e8d..1681c6df8 100644
--- a/scrapy/core/downloader/handlers/ftp.py
+++ b/scrapy/core/downloader/handlers/ftp.py
@@ -30,11 +30,11 @@ In case of status 200 request, response.headers will come with two keys:
import re
from io import BytesIO
-from six.moves.urllib.parse import unquote
+from urllib.parse import unquote
from twisted.internet import reactor
-from twisted.protocols.ftp import FTPClient, CommandFailed
-from twisted.internet.protocol import Protocol, ClientCreator
+from twisted.internet.protocol import ClientCreator, Protocol
+from twisted.protocols.ftp import CommandFailed, FTPClient
from scrapy.http import Response
from scrapy.responsetypes import responsetypes
@@ -59,8 +59,12 @@ class ReceivedDataProtocol(Protocol):
def close(self):
self.body.close() if self.filename else self.body.seek(0)
-_CODE_RE = re.compile("\d+")
-class FTPDownloadHandler(object):
+
+_CODE_RE = re.compile(r"\d+")
+
+
+class FTPDownloadHandler:
+ lazy = False
CODE_MAPPING = {
"550": 404,
@@ -72,6 +76,10 @@ class FTPDownloadHandler(object):
self.default_password = settings['FTP_PASSWORD']
self.passive_mode = settings['FTP_PASSIVE_MODE']
+ @classmethod
+ def from_crawler(cls, crawler):
+ return cls(crawler.settings)
+
def download_request(self, request, spider):
parsed_url = urlparse_cached(request)
user = request.meta.get("ftp_user", self.default_user)
@@ -109,4 +117,3 @@ class FTPDownloadHandler(object):
httpcode = self.CODE_MAPPING.get(ftpcode, self.CODE_MAPPING["default"])
return Response(url=request.url, status=httpcode, body=to_bytes(message))
raise result.type(result.value)
-
diff --git a/scrapy/core/downloader/handlers/http.py b/scrapy/core/downloader/handlers/http.py
index e4a7d8564..52535bd8b 100644
--- a/scrapy/core/downloader/handlers/http.py
+++ b/scrapy/core/downloader/handlers/http.py
@@ -1,15 +1,4 @@
-from __future__ import absolute_import
-from .http10 import HTTP10DownloadHandler
-from .http11 import HTTP11DownloadHandler as HTTPDownloadHandler
-
-
-# backwards compatibility
-class HttpDownloadHandler(HTTP10DownloadHandler):
-
- def __init__(self, *args, **kwargs):
- import warnings
- from scrapy.exceptions import ScrapyDeprecationWarning
- warnings.warn('HttpDownloadHandler is deprecated, import scrapy.core.downloader'
- '.handlers.http10.HTTP10DownloadHandler instead',
- category=ScrapyDeprecationWarning, stacklevel=1)
- super(HttpDownloadHandler, self).__init__(*args, **kwargs)
+from scrapy.core.downloader.handlers.http10 import HTTP10DownloadHandler
+from scrapy.core.downloader.handlers.http11 import (
+ HTTP11DownloadHandler as HTTPDownloadHandler,
+)
diff --git a/scrapy/core/downloader/handlers/http10.py b/scrapy/core/downloader/handlers/http10.py
index 0322bbe49..d4aa51bd1 100644
--- a/scrapy/core/downloader/handlers/http10.py
+++ b/scrapy/core/downloader/handlers/http10.py
@@ -1,15 +1,23 @@
"""Download handlers for http and https schemes
"""
from twisted.internet import reactor
-from scrapy.utils.misc import load_object
+
+from scrapy.utils.misc import create_instance, load_object
from scrapy.utils.python import to_unicode
-class HTTP10DownloadHandler(object):
+class HTTP10DownloadHandler:
+ lazy = False
- def __init__(self, settings):
+ def __init__(self, settings, crawler=None):
self.HTTPClientFactory = load_object(settings['DOWNLOADER_HTTPCLIENTFACTORY'])
self.ClientContextFactory = load_object(settings['DOWNLOADER_CLIENTCONTEXTFACTORY'])
+ self._settings = settings
+ self._crawler = crawler
+
+ @classmethod
+ def from_crawler(cls, crawler):
+ return cls(crawler.settings, crawler)
def download_request(self, request, spider):
"""Return a deferred for the HTTP download"""
@@ -20,7 +28,11 @@ class HTTP10DownloadHandler(object):
def _connect(self, factory):
host, port = to_unicode(factory.host), factory.port
if factory.scheme == b'https':
- return reactor.connectSSL(host, port, factory,
- self.ClientContextFactory())
+ client_context_factory = create_instance(
+ objcls=self.ClientContextFactory,
+ settings=self._settings,
+ crawler=self._crawler,
+ )
+ return reactor.connectSSL(host, port, factory, client_context_factory)
else:
return reactor.connectTCP(host, port, factory)
diff --git a/scrapy/core/downloader/handlers/http11.py b/scrapy/core/downloader/handlers/http11.py
index 038db7b47..5a5f6cf0a 100644
--- a/scrapy/core/downloader/handlers/http11.py
+++ b/scrapy/core/downloader/handlers/http11.py
@@ -1,40 +1,37 @@
"""Download handlers for http and https schemes"""
-import re
import logging
+import re
+import warnings
from io import BytesIO
from time import time
-import warnings
-from six.moves.urllib.parse import urldefrag
+from urllib.parse import urldefrag
-from zope.interface import implementer
-from twisted.internet import defer, reactor, protocol
+from twisted.internet import defer, protocol, reactor
+from twisted.internet.endpoints import TCP4ClientEndpoint
+from twisted.internet.error import TimeoutError
+from twisted.web.client import Agent, HTTPConnectionPool, ResponseDone, ResponseFailed, URI
+from twisted.web.http import _DataLoss, PotentialDataLoss
from twisted.web.http_headers import Headers as TxHeaders
from twisted.web.iweb import IBodyProducer, UNKNOWN_LENGTH
-from twisted.internet.error import TimeoutError
-from twisted.web.http import _DataLoss, PotentialDataLoss
-from twisted.web.client import Agent, ProxyAgent, ResponseDone, \
- HTTPConnectionPool, ResponseFailed
-try:
- from twisted.web.client import URI
-except ImportError:
- from twisted.web.client import _URI as URI
-from twisted.internet.endpoints import TCP4ClientEndpoint
+from zope.interface import implementer
+from scrapy.core.downloader.tls import openssl_methods
+from scrapy.core.downloader.webclient import _parse
+from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.http import Headers
from scrapy.responsetypes import responsetypes
-from scrapy.core.downloader.webclient import _parse
-from scrapy.core.downloader.tls import openssl_methods
-from scrapy.utils.misc import load_object
+from scrapy.utils.misc import create_instance, load_object
from scrapy.utils.python import to_bytes, to_unicode
-from scrapy import twisted_version
+
logger = logging.getLogger(__name__)
-class HTTP11DownloadHandler(object):
+class HTTP11DownloadHandler:
+ lazy = False
- def __init__(self, settings):
+ def __init__(self, settings, crawler=None):
self._pool = HTTPConnectionPool(reactor, persistent=True)
self._pool.maxPersistentPerHost = settings.getint('CONCURRENT_REQUESTS_PER_DOMAIN')
self._pool._factory.noisy = False
@@ -43,14 +40,23 @@ class HTTP11DownloadHandler(object):
self._contextFactoryClass = load_object(settings['DOWNLOADER_CLIENTCONTEXTFACTORY'])
# try method-aware context factory
try:
- self._contextFactory = self._contextFactoryClass(method=self._sslMethod)
+ self._contextFactory = create_instance(
+ objcls=self._contextFactoryClass,
+ settings=settings,
+ crawler=crawler,
+ method=self._sslMethod,
+ )
except TypeError:
# use context factory defaults
- self._contextFactory = self._contextFactoryClass()
+ self._contextFactory = create_instance(
+ objcls=self._contextFactoryClass,
+ settings=settings,
+ crawler=crawler,
+ )
msg = """
'%s' does not accept `method` argument (type OpenSSL.SSL method,\
- e.g. OpenSSL.SSL.SSLv23_METHOD).\
- Please upgrade your context factory class to handle it or ignore it.""" % (
+ e.g. OpenSSL.SSL.SSLv23_METHOD) and/or `tls_verbose_logging` argument and/or `tls_ciphers` argument.\
+ Please upgrade your context factory class to handle them or ignore them.""" % (
settings['DOWNLOADER_CLIENTCONTEXTFACTORY'],)
warnings.warn(msg)
self._default_maxsize = settings.getint('DOWNLOAD_MAXSIZE')
@@ -58,12 +64,19 @@ class HTTP11DownloadHandler(object):
self._fail_on_dataloss = settings.getbool('DOWNLOAD_FAIL_ON_DATALOSS')
self._disconnect_timeout = 1
+ @classmethod
+ def from_crawler(cls, crawler):
+ return cls(crawler.settings, crawler)
+
def download_request(self, request, spider):
"""Return a deferred for the HTTP download"""
- agent = ScrapyAgent(contextFactory=self._contextFactory, pool=self._pool,
+ agent = ScrapyAgent(
+ contextFactory=self._contextFactory,
+ pool=self._pool,
maxsize=getattr(spider, 'download_maxsize', self._default_maxsize),
warnsize=getattr(spider, 'download_warnsize', self._default_warnsize),
- fail_on_dataloss=self._fail_on_dataloss)
+ fail_on_dataloss=self._fail_on_dataloss,
+ )
return agent.download_request(request)
def close(self):
@@ -100,13 +113,11 @@ class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint):
for it.
"""
- _responseMatcher = re.compile(b'HTTP/1\.. (?P\d{3})(?P.{,32})')
+ _responseMatcher = re.compile(br'HTTP/1\.. (?P\d{3})(?P.{,32})')
- def __init__(self, reactor, host, port, proxyConf, contextFactory,
- timeout=30, bindAddress=None):
+ def __init__(self, reactor, host, port, proxyConf, contextFactory, timeout=30, bindAddress=None):
proxyHost, proxyPort, self._proxyAuthHeader = proxyConf
- super(TunnelingTCP4ClientEndpoint, self).__init__(reactor, proxyHost,
- proxyPort, timeout, bindAddress)
+ super(TunnelingTCP4ClientEndpoint, self).__init__(reactor, proxyHost, proxyPort, timeout, bindAddress)
self._tunnelReadyDeferred = defer.Deferred()
self._tunneledHost = host
self._tunneledPort = port
@@ -115,8 +126,7 @@ class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint):
def requestTunnel(self, protocol):
"""Asks the proxy to open a tunnel."""
- tunnelReq = tunnel_request_data(self._tunneledHost, self._tunneledPort,
- self._proxyAuthHeader)
+ tunnelReq = tunnel_request_data(self._tunneledHost, self._tunneledPort, self._proxyAuthHeader)
protocol.transport.write(tunnelReq)
self._protocolDataReceived = protocol.dataReceived
protocol.dataReceived = self.processProxyResponse
@@ -139,16 +149,9 @@ class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint):
self._protocol.dataReceived = self._protocolDataReceived
respm = TunnelingTCP4ClientEndpoint._responseMatcher.match(self._connectBuffer)
if respm and int(respm.group('status')) == 200:
- try:
- # this sets proper Server Name Indication extension
- # but is only available for Twisted>=14.0
- sslOptions = self._contextFactory.creatorForNetloc(
- self._tunneledHost, self._tunneledPort)
- except AttributeError:
- # fall back to non-SNI SSL context factory
- sslOptions = self._contextFactory
- self._protocol.transport.startTLS(sslOptions,
- self._protocolFactory)
+ # set proper Server Name Indication extension
+ sslOptions = self._contextFactory.creatorForNetloc(self._tunneledHost, self._tunneledPort)
+ self._protocol.transport.startTLS(sslOptions, self._protocolFactory)
self._tunnelReadyDeferred.callback(self._protocol)
else:
if respm:
@@ -166,8 +169,7 @@ class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint):
def connect(self, protocolFactory):
self._protocolFactory = protocolFactory
- connectDeferred = super(TunnelingTCP4ClientEndpoint,
- self).connect(protocolFactory)
+ connectDeferred = super(TunnelingTCP4ClientEndpoint, self).connect(protocolFactory)
connectDeferred.addCallback(self.requestTunnel)
connectDeferred.addErrback(self.connectFailed)
return self._tunnelReadyDeferred
@@ -177,7 +179,7 @@ def tunnel_request_data(host, port, proxy_auth_header=None):
r"""
Return binary content of a CONNECT request.
- >>> from scrapy.utils.python import to_native_str as s
+ >>> from scrapy.utils.python import to_unicode as s
>>> s(tunnel_request_data("example.com", 8080))
'CONNECT example.com:8080 HTTP/1.1\r\nHost: example.com:8080\r\n\r\n'
>>> s(tunnel_request_data("example.com", 8080, b"123"))
@@ -204,42 +206,46 @@ class TunnelingAgent(Agent):
def __init__(self, reactor, proxyConf, contextFactory=None,
connectTimeout=None, bindAddress=None, pool=None):
- super(TunnelingAgent, self).__init__(reactor, contextFactory,
- connectTimeout, bindAddress, pool)
+ super(TunnelingAgent, self).__init__(reactor, contextFactory, connectTimeout, bindAddress, pool)
self._proxyConf = proxyConf
self._contextFactory = contextFactory
- if twisted_version >= (15, 0, 0):
- def _getEndpoint(self, uri):
- return TunnelingTCP4ClientEndpoint(
- self._reactor, uri.host, uri.port, self._proxyConf,
- self._contextFactory, self._endpointFactory._connectTimeout,
- self._endpointFactory._bindAddress)
- else:
- def _getEndpoint(self, scheme, host, port):
- return TunnelingTCP4ClientEndpoint(
- self._reactor, host, port, self._proxyConf,
- self._contextFactory, self._connectTimeout,
- self._bindAddress)
+ def _getEndpoint(self, uri):
+ return TunnelingTCP4ClientEndpoint(
+ reactor=self._reactor,
+ host=uri.host,
+ port=uri.port,
+ proxyConf=self._proxyConf,
+ contextFactory=self._contextFactory,
+ timeout=self._endpointFactory._connectTimeout,
+ bindAddress=self._endpointFactory._bindAddress,
+ )
- def _requestWithEndpoint(self, key, endpoint, method, parsedURI,
- headers, bodyProducer, requestPath):
+ def _requestWithEndpoint(self, key, endpoint, method, parsedURI, headers, bodyProducer, requestPath):
# proxy host and port are required for HTTP pool `key`
# otherwise, same remote host connection request could reuse
# a cached tunneled connection to a different proxy
key = key + self._proxyConf
- return super(TunnelingAgent, self)._requestWithEndpoint(key, endpoint, method, parsedURI,
- headers, bodyProducer, requestPath)
+ return super(TunnelingAgent, self)._requestWithEndpoint(
+ key=key,
+ endpoint=endpoint,
+ method=method,
+ parsedURI=parsedURI,
+ headers=headers,
+ bodyProducer=bodyProducer,
+ requestPath=requestPath,
+ )
class ScrapyProxyAgent(Agent):
- def __init__(self, reactor, proxyURI,
- connectTimeout=None, bindAddress=None, pool=None):
- super(ScrapyProxyAgent, self).__init__(reactor,
- connectTimeout=connectTimeout,
- bindAddress=bindAddress,
- pool=pool)
+ def __init__(self, reactor, proxyURI, connectTimeout=None, bindAddress=None, pool=None):
+ super(ScrapyProxyAgent, self).__init__(
+ reactor=reactor,
+ connectTimeout=connectTimeout,
+ bindAddress=bindAddress,
+ pool=pool,
+ )
self._proxyURI = URI.fromBytes(proxyURI)
def request(self, method, uri, headers=None, bodyProducer=None):
@@ -248,16 +254,15 @@ class ScrapyProxyAgent(Agent):
"""
# Cache *all* connections under the same key, since we are only
# connecting to a single destination, the proxy:
- if twisted_version >= (15, 0, 0):
- proxyEndpoint = self._getEndpoint(self._proxyURI)
- else:
- proxyEndpoint = self._getEndpoint(self._proxyURI.scheme,
- self._proxyURI.host,
- self._proxyURI.port)
- key = ("http-proxy", self._proxyURI.host, self._proxyURI.port)
- return self._requestWithEndpoint(key, proxyEndpoint, method,
- URI.fromBytes(uri), headers,
- bodyProducer, uri)
+ return self._requestWithEndpoint(
+ key=("http-proxy", self._proxyURI.host, self._proxyURI.port),
+ endpoint=self._getEndpoint(self._proxyURI),
+ method=method,
+ parsedURI=URI.fromBytes(uri),
+ headers=headers,
+ bodyProducer=bodyProducer,
+ requestPath=uri,
+ )
class ScrapyAgent(object):
@@ -285,18 +290,39 @@ class ScrapyAgent(object):
scheme = _parse(request.url)[0]
proxyHost = to_unicode(proxyHost)
omitConnectTunnel = b'noconnect' in proxyParams
- if scheme == b'https' and not omitConnectTunnel:
- proxyConf = (proxyHost, proxyPort,
- request.headers.get(b'Proxy-Authorization', None))
- return self._TunnelingAgent(reactor, proxyConf,
- contextFactory=self._contextFactory, connectTimeout=timeout,
- bindAddress=bindaddress, pool=self._pool)
+ if omitConnectTunnel:
+ warnings.warn("Using HTTPS proxies in the noconnect mode is deprecated. "
+ "If you use Crawlera, it doesn't require this mode anymore, "
+ "so you should update scrapy-crawlera to 1.3.0+ "
+ "and remove '?noconnect' from the Crawlera URL.",
+ ScrapyDeprecationWarning)
+ if scheme == b'https' and not omitConnectTunnel:
+ proxyAuth = request.headers.get(b'Proxy-Authorization', None)
+ proxyConf = (proxyHost, proxyPort, proxyAuth)
+ return self._TunnelingAgent(
+ reactor=reactor,
+ proxyConf=proxyConf,
+ contextFactory=self._contextFactory,
+ connectTimeout=timeout,
+ bindAddress=bindaddress,
+ pool=self._pool,
+ )
else:
- return self._ProxyAgent(reactor, proxyURI=to_bytes(proxy, encoding='ascii'),
- connectTimeout=timeout, bindAddress=bindaddress, pool=self._pool)
+ return self._ProxyAgent(
+ reactor=reactor,
+ proxyURI=to_bytes(proxy, encoding='ascii'),
+ connectTimeout=timeout,
+ bindAddress=bindaddress,
+ pool=self._pool,
+ )
- return self._Agent(reactor, contextFactory=self._contextFactory,
- connectTimeout=timeout, bindAddress=bindaddress, pool=self._pool)
+ return self._Agent(
+ reactor=reactor,
+ contextFactory=self._contextFactory,
+ connectTimeout=timeout,
+ bindAddress=bindaddress,
+ pool=self._pool,
+ )
def download_request(self, request):
timeout = request.meta.get('download_timeout') or self._connectTimeout
@@ -327,8 +353,7 @@ class ScrapyAgent(object):
else:
bodyproducer = None
start_time = time()
- d = agent.request(
- method, to_bytes(url, encoding='ascii'), headers, bodyproducer)
+ d = agent.request(method, to_bytes(url, encoding='ascii'), headers, bodyproducer)
# set download latency
d.addCallback(self._cb_latency, request, start_time)
# response body is ready to be consumed
@@ -383,8 +408,9 @@ class ScrapyAgent(object):
txresponse._transport._producer.abortConnection()
d = defer.Deferred(_cancel)
- txresponse.deliverBody(_ResponseReader(
- d, txresponse, request, maxsize, warnsize, fail_on_dataloss))
+ txresponse.deliverBody(
+ _ResponseReader(d, txresponse, request, maxsize, warnsize, fail_on_dataloss)
+ )
# save response for timeouts
self._txresponse = txresponse
@@ -419,22 +445,20 @@ class _RequestBodyProducer(object):
class _ResponseReader(protocol.Protocol):
- def __init__(self, finished, txresponse, request, maxsize, warnsize,
- fail_on_dataloss):
+ def __init__(self, finished, txresponse, request, maxsize, warnsize, fail_on_dataloss):
self._finished = finished
self._txresponse = txresponse
self._request = request
self._bodybuf = BytesIO()
- self._maxsize = maxsize
- self._warnsize = warnsize
+ self._maxsize = maxsize
+ self._warnsize = warnsize
self._fail_on_dataloss = fail_on_dataloss
self._fail_on_dataloss_warned = False
self._reached_warnsize = False
self._bytes_received = 0
def dataReceived(self, bodyBytes):
- # This maybe called several times after cancel was called with buffered
- # data.
+ # This maybe called several times after cancel was called with buffered data.
if self._finished.called:
return
@@ -447,8 +471,7 @@ class _ResponseReader(protocol.Protocol):
{'bytes': self._bytes_received,
'maxsize': self._maxsize,
'request': self._request})
- # Clear buffer earlier to avoid keeping data in memory for a long
- # time.
+ # Clear buffer earlier to avoid keeping data in memory for a long time.
self._bodybuf.truncate(0)
self._finished.cancel()
@@ -478,10 +501,10 @@ class _ResponseReader(protocol.Protocol):
return
elif not self._fail_on_dataloss_warned:
- logger.warn("Got data loss in %s. If you want to process broken "
- "responses set the setting DOWNLOAD_FAIL_ON_DATALOSS = False"
- " -- This message won't be shown in further requests",
- self._txresponse.request.absoluteURI.decode())
+ logger.warning("Got data loss in %s. If you want to process broken "
+ "responses set the setting DOWNLOAD_FAIL_ON_DATALOSS = False"
+ " -- This message won't be shown in further requests",
+ self._txresponse.request.absoluteURI.decode())
self._fail_on_dataloss_warned = True
self._finished.errback(reason)
diff --git a/scrapy/core/downloader/handlers/s3.py b/scrapy/core/downloader/handlers/s3.py
index d8bbdd326..40a1fa48e 100644
--- a/scrapy/core/downloader/handlers/s3.py
+++ b/scrapy/core/downloader/handlers/s3.py
@@ -1,9 +1,10 @@
-from six.moves.urllib.parse import unquote
+from urllib.parse import unquote
+from scrapy.core.downloader.handlers.http import HTTPDownloadHandler
from scrapy.exceptions import NotConfigured
-from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.boto import is_botocore
-from .http import HTTPDownloadHandler
+from scrapy.utils.httpobj import urlparse_cached
+from scrapy.utils.misc import create_instance
def _get_boto_connection():
@@ -21,7 +22,7 @@ def _get_boto_connection():
return http_request.headers
try:
- import boto.auth
+ import boto.auth # noqa: F401
except ImportError:
_S3Connection = _v19_S3Connection
else:
@@ -30,11 +31,12 @@ def _get_boto_connection():
return _S3Connection
-class S3DownloadHandler(object):
-
- def __init__(self, settings, aws_access_key_id=None, aws_secret_access_key=None, \
- httpdownloadhandler=HTTPDownloadHandler, **kw):
+class S3DownloadHandler:
+ def __init__(self, settings, *,
+ crawler=None,
+ aws_access_key_id=None, aws_secret_access_key=None,
+ httpdownloadhandler=HTTPDownloadHandler, **kw):
if not aws_access_key_id:
aws_access_key_id = settings['AWS_ACCESS_KEY_ID']
if not aws_secret_access_key:
@@ -67,7 +69,16 @@ class S3DownloadHandler(object):
except Exception as ex:
raise NotConfigured(str(ex))
- self._download_http = httpdownloadhandler(settings).download_request
+ _http_handler = create_instance(
+ objcls=httpdownloadhandler,
+ settings=settings,
+ crawler=crawler,
+ )
+ self._download_http = _http_handler.download_request
+
+ @classmethod
+ def from_crawler(cls, crawler, **kwargs):
+ return cls(crawler.settings, crawler=crawler, **kwargs)
def download_request(self, request, spider):
p = urlparse_cached(request)
diff --git a/scrapy/core/downloader/middleware.py b/scrapy/core/downloader/middleware.py
index c3b23e284..9c0014206 100644
--- a/scrapy/core/downloader/middleware.py
+++ b/scrapy/core/downloader/middleware.py
@@ -3,13 +3,12 @@ Downloader Middleware manager
See documentation in docs/topics/downloader-middleware.rst
"""
-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
+from scrapy.utils.defer import mustbe_deferred, deferred_from_coro
from scrapy.utils.conf import build_component_list
@@ -26,21 +25,21 @@ class DownloaderMiddlewareManager(MiddlewareManager):
if hasattr(mw, 'process_request'):
self.methods['process_request'].append(mw.process_request)
if hasattr(mw, 'process_response'):
- self.methods['process_response'].insert(0, mw.process_response)
+ self.methods['process_response'].appendleft(mw.process_response)
if hasattr(mw, 'process_exception'):
- self.methods['process_exception'].insert(0, mw.process_exception)
+ self.methods['process_exception'].appendleft(mw.process_exception)
def download(self, download_func, request, spider):
@defer.inlineCallbacks
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__)
+ response = yield deferred_from_coro(method(request=request, spider=spider))
+ 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' % \
+ (method.__self__.__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):
@@ -49,11 +48,10 @@ class DownloaderMiddlewareManager(MiddlewareManager):
defer.returnValue(response)
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))
+ response = yield deferred_from_coro(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' % \
+ (method.__self__.__class__.__name__, type(response)))
if isinstance(response, Request):
defer.returnValue(response)
defer.returnValue(response)
@@ -62,11 +60,10 @@ 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)
- 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))
+ response = yield deferred_from_coro(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' % \
+ (method.__self__.__class__.__name__, type(response)))
if response:
defer.returnValue(response)
defer.returnValue(_failure)
diff --git a/scrapy/core/downloader/tls.py b/scrapy/core/downloader/tls.py
index df8051182..4ed482058 100644
--- a/scrapy/core/downloader/tls.py
+++ b/scrapy/core/downloader/tls.py
@@ -1,17 +1,24 @@
import logging
+
from OpenSSL import SSL
+from service_identity.exceptions import CertificateError
+from twisted.internet._sslverify import ClientTLSOptions, verifyHostname, VerificationError
+from twisted.internet.ssl import AcceptableCiphers
from scrapy import twisted_version
+from scrapy.utils.ssl import x509name_to_string, get_temp_key_info
logger = logging.getLogger(__name__)
+
METHOD_SSLv3 = 'SSLv3'
METHOD_TLS = 'TLS'
METHOD_TLSv10 = 'TLSv1.0'
METHOD_TLSv11 = 'TLSv1.1'
METHOD_TLSv12 = 'TLSv1.2'
+
openssl_methods = {
METHOD_TLS: SSL.SSLv23_METHOD, # protocol negotiation (recommended)
METHOD_SSLv3: SSL.SSLv3_METHOD, # SSL 3 (NOT recommended)
@@ -20,69 +27,66 @@ openssl_methods = {
METHOD_TLSv12: getattr(SSL, 'TLSv1_2_METHOD', 6), # TLS 1.2 only
}
-if twisted_version >= (14, 0, 0):
- # ClientTLSOptions requires a recent-enough version of Twisted.
- # Not having ScrapyClientTLSOptions should not matter for older
- # Twisted versions because it is not used in the fallback
- # ScrapyClientContextFactory.
- # taken from twisted/twisted/internet/_sslverify.py
-
- try:
- # XXX: this try-except is not needed in Twisted 17.0.0+ because
- # it requires pyOpenSSL 0.16+.
- from OpenSSL.SSL import SSL_CB_HANDSHAKE_DONE, SSL_CB_HANDSHAKE_START
- except ImportError:
- SSL_CB_HANDSHAKE_START = 0x10
- SSL_CB_HANDSHAKE_DONE = 0x20
-
- from twisted.internet.ssl import AcceptableCiphers
- from twisted.internet._sslverify import (ClientTLSOptions,
- verifyHostname,
- VerificationError)
- try:
- # XXX: this import would fail on Debian jessie with system installed
- # service_identity library, due to lack of cryptography.x509 dependency
- # See https://github.com/pyca/service_identity/issues/21
- from service_identity.exceptions import CertificateError
- verification_errors = (CertificateError, VerificationError)
- except ImportError:
- verification_errors = VerificationError
-
- if twisted_version < (17, 0, 0):
- from twisted.internet._sslverify import _maybeSetHostNameIndication
- set_tlsext_host_name = _maybeSetHostNameIndication
- else:
- def set_tlsext_host_name(connection, hostNameBytes):
- connection.set_tlsext_host_name(hostNameBytes)
+if twisted_version < (17, 0, 0):
+ from twisted.internet._sslverify import _maybeSetHostNameIndication as set_tlsext_host_name
+else:
+ def set_tlsext_host_name(connection, hostNameBytes):
+ connection.set_tlsext_host_name(hostNameBytes)
- class ScrapyClientTLSOptions(ClientTLSOptions):
- """
- SSL Client connection creator ignoring certificate verification errors
- (for genuinely invalid certificates or bugs in verification code).
+class ScrapyClientTLSOptions(ClientTLSOptions):
+ """
+ SSL Client connection creator ignoring certificate verification errors
+ (for genuinely invalid certificates or bugs in verification code).
- Same as Twisted's private _sslverify.ClientTLSOptions,
- except that VerificationError, CertificateError and ValueError
- exceptions are caught, so that the connection is not closed, only
- logging warnings.
- """
+ Same as Twisted's private _sslverify.ClientTLSOptions,
+ except that VerificationError, CertificateError and ValueError
+ exceptions are caught, so that the connection is not closed, only
+ logging warnings. Also, HTTPS connection parameters logging is added.
+ """
- def _identityVerifyingInfoCallback(self, connection, where, ret):
- if where & SSL_CB_HANDSHAKE_START:
- set_tlsext_host_name(connection, self._hostnameBytes)
- elif where & SSL_CB_HANDSHAKE_DONE:
- try:
- verifyHostname(connection, self._hostnameASCII)
- except verification_errors as e:
- logger.warning(
- 'Remote certificate is not valid for hostname "{}"; {}'.format(
- self._hostnameASCII, e))
+ def __init__(self, hostname, ctx, verbose_logging=False):
+ super(ScrapyClientTLSOptions, self).__init__(hostname, ctx)
+ self.verbose_logging = verbose_logging
- except ValueError as e:
- logger.warning(
- 'Ignoring error while verifying certificate '
- 'from host "{}" (exception: {})'.format(
- self._hostnameASCII, repr(e)))
+ def _identityVerifyingInfoCallback(self, connection, where, ret):
+ if where & SSL.SSL_CB_HANDSHAKE_START:
+ set_tlsext_host_name(connection, self._hostnameBytes)
+ elif where & SSL.SSL_CB_HANDSHAKE_DONE:
+ if self.verbose_logging:
+ if hasattr(connection, 'get_cipher_name'): # requires pyOPenSSL 0.15
+ if hasattr(connection, 'get_protocol_version_name'): # requires pyOPenSSL 16.0.0
+ logger.debug('SSL connection to %s using protocol %s, cipher %s',
+ self._hostnameASCII,
+ connection.get_protocol_version_name(),
+ connection.get_cipher_name(),
+ )
+ else:
+ logger.debug('SSL connection to %s using cipher %s',
+ self._hostnameASCII,
+ connection.get_cipher_name(),
+ )
+ server_cert = connection.get_peer_certificate()
+ logger.debug('SSL connection certificate: issuer "%s", subject "%s"',
+ x509name_to_string(server_cert.get_issuer()),
+ x509name_to_string(server_cert.get_subject()),
+ )
+ key_info = get_temp_key_info(connection._ssl)
+ if key_info:
+ logger.debug('SSL temp key: %s', key_info)
- DEFAULT_CIPHERS = AcceptableCiphers.fromOpenSSLCipherString('DEFAULT')
+ try:
+ verifyHostname(connection, self._hostnameASCII)
+ except (CertificateError, VerificationError) as e:
+ logger.warning(
+ 'Remote certificate is not valid for hostname "{}"; {}'.format(
+ self._hostnameASCII, e))
+
+ except ValueError as e:
+ logger.warning(
+ 'Ignoring error while verifying certificate '
+ 'from host "{}" (exception: {})'.format(
+ self._hostnameASCII, repr(e)))
+
+DEFAULT_CIPHERS = AcceptableCiphers.fromOpenSSLCipherString('DEFAULT')
diff --git a/scrapy/core/downloader/webclient.py b/scrapy/core/downloader/webclient.py
index 1c89a0f9e..fc796e8bb 100644
--- a/scrapy/core/downloader/webclient.py
+++ b/scrapy/core/downloader/webclient.py
@@ -1,5 +1,5 @@
from time import time
-from six.moves.urllib.parse import urlparse, urlunparse, urldefrag
+from urllib.parse import urlparse, urlunparse, urldefrag
from twisted.web.client import HTTPClientFactory
from twisted.web.http import HTTPClient
@@ -42,7 +42,7 @@ class ScrapyHTTPPageGetter(HTTPClient):
delimiter = b'\n'
def connectionMade(self):
- self.headers = Headers() # bucket for response headers
+ self.headers = Headers() # bucket for response headers
# Method command
self.sendCommand(self.factory.method, self.factory.path)
@@ -88,14 +88,14 @@ class ScrapyHTTPPageGetter(HTTPClient):
if self.factory.url.startswith(b'https'):
self.transport.stopProducing()
- self.factory.noPage(\
- defer.TimeoutError("Getting %s took longer than %s seconds." % \
- (self.factory.url, self.factory.timeout)))
+ self.factory.noPage(
+ defer.TimeoutError("Getting %s took longer than %s seconds." %
+ (self.factory.url, self.factory.timeout)))
class ScrapyHTTPClientFactory(HTTPClientFactory):
"""Scrapy implementation of the HTTPClientFactory overwriting the
- serUrl method to make use of our Url object that cache the parse
+ setUrl method to make use of our Url object that cache the parse
result.
"""
@@ -157,4 +157,3 @@ class ScrapyHTTPClientFactory(HTTPClientFactory):
def gotHeaders(self, headers):
self.headers_time = time()
self.response_headers = headers
-
diff --git a/scrapy/core/engine.py b/scrapy/core/engine.py
index 9d7552774..268ac75a5 100644
--- a/scrapy/core/engine.py
+++ b/scrapy/core/engine.py
@@ -25,7 +25,7 @@ class Slot(object):
def __init__(self, start_requests, close_if_idle, nextcall, scheduler):
self.closing = False
- self.inprogress = set() # requests in progress
+ self.inprogress = set() # requests in progress
self.start_requests = iter(start_requests)
self.close_if_idle = close_if_idle
self.nextcall = nextcall
@@ -236,10 +236,11 @@ class ExecutionEngine(object):
def _on_success(response):
assert isinstance(response, (Response, Request))
if isinstance(response, Response):
- response.request = request # tie request to response received
+ response.request = request # tie request to response received
logkws = self.logformatter.crawled(request, response, spider)
- logger.log(*logformatter_adapter(logkws), extra={'spider': spider})
- self.signals.send_catch_log(signal=signals.response_received, \
+ if logkws is not None:
+ logger.log(*logformatter_adapter(logkws), extra={'spider': spider})
+ self.signals.send_catch_log(signal=signals.response_received,
response=response, request=request, spider=spider)
return response
diff --git a/scrapy/core/scheduler.py b/scrapy/core/scheduler.py
index a54b4daf0..975aede0c 100644
--- a/scrapy/core/scheduler.py
+++ b/scrapy/core/scheduler.py
@@ -1,19 +1,46 @@
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 scrapy.utils.misc import load_object
+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):
+ logunser=False, stats=None, pqclass=None, crawler=None):
self.df = dupefilter
self.dqdir = self._dqdir(jobdir)
self.pqclass = pqclass
@@ -21,33 +48,43 @@ class Scheduler(object):
self.mqclass = mqclass
self.logunser = logunser
self.stats = stats
+ self.crawler = crawler
@classmethod
def from_crawler(cls, crawler):
settings = crawler.settings
dupefilter_cls = load_object(settings['DUPEFILTER_CLASS'])
- dupefilter = dupefilter_cls.from_settings(settings)
+ dupefilter = create_instance(dupefilter_cls, settings, crawler)
pqclass = load_object(settings['SCHEDULER_PRIORITY_QUEUE'])
+ if pqclass is PriorityQueue:
+ 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)
+ 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 = self._mq()
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):
@@ -82,8 +119,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:"
@@ -103,32 +139,51 @@ 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 _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 = ()
- q = self.pqclass(self._newdq, startprios=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,
+ 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/core/scraper.py b/scrapy/core/scraper.py
index c08e37367..facbd8b73 100644
--- a/scrapy/core/scraper.py
+++ b/scrapy/core/scraper.py
@@ -9,7 +9,7 @@ from twisted.internet import defer
from scrapy.utils.defer import defer_result, defer_succeed, parallel, iter_errback
from scrapy.utils.spider import iterate_spider_output
-from scrapy.utils.misc import load_object
+from scrapy.utils.misc import load_object, warn_on_generator_with_return_value
from scrapy.utils.log import logformatter_adapter, failure_to_exc_info
from scrapy.exceptions import CloseSpider, DropItem, IgnoreRequest
from scrapy import signals
@@ -18,6 +18,7 @@ from scrapy.item import BaseItem
from scrapy.core.spidermw import SpiderMiddlewareManager
from scrapy.utils.request import referer_str
+
logger = logging.getLogger(__name__)
@@ -77,7 +78,7 @@ class Scraper(object):
@defer.inlineCallbacks
def open_spider(self, spider):
"""Open the given spider for scraping and allocate resources for it"""
- self.slot = Slot()
+ self.slot = Slot(self.crawler.settings.getint('SCRAPER_SLOT_MAX_ACTIVE_SIZE'))
yield self.itemproc.open_spider(spider)
def close_spider(self, spider):
@@ -99,11 +100,13 @@ class Scraper(object):
def enqueue_scrape(self, response, request, spider):
slot = self.slot
dfd = slot.add_response_request(response, request)
+
def finish_scraping(_):
slot.finish_response(response, request)
self._check_if_closing(spider, slot)
self._scrape_next(spider, slot)
return _
+
dfd.addBoth(finish_scraping)
dfd.addErrback(
lambda f: logger.error('Scraper bug processing %(request)s',
@@ -123,7 +126,7 @@ class Scraper(object):
callback/errback"""
assert isinstance(response, (Response, Failure))
- dfd = self._scrape2(response, request, spider) # returns spiders processed output
+ dfd = self._scrape2(response, request, spider) # returns spider's processed output
dfd.addErrback(self.handle_spider_error, request, response, spider)
dfd.addCallback(self.handle_spider_output, request, response, spider)
return dfd
@@ -135,7 +138,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)
@@ -143,7 +145,12 @@ 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)
+ callback = request.callback or spider.parse
+ warn_on_generator_with_return_value(spider, callback)
+ warn_on_generator_with_return_value(spider, request.errback)
+ dfd.addCallbacks(callback=callback,
+ errback=request.errback,
+ callbackKeywords=request.cb_kwargs)
return dfd.addCallback(iterate_spider_output)
def handle_spider_error(self, _failure, request, response, spider):
@@ -171,8 +178,8 @@ class Scraper(object):
if not result:
return defer_succeed(None)
it = iter_errback(result, self.handle_spider_error, request, response, spider)
- dfd = parallel(it, self.concurrent_items,
- self._process_spidermw_output, request, response, spider)
+ dfd = parallel(it, self.concurrent_items, self._process_spidermw_output,
+ request, response, spider)
return dfd
def _process_spidermw_output(self, output, request, response, spider):
@@ -199,8 +206,7 @@ class Scraper(object):
"""Log and silence errors that come from the engine (typically download
errors that got propagated thru here)
"""
- if (isinstance(download_failure, Failure) and
- not download_failure.check(IgnoreRequest)):
+ if isinstance(download_failure, Failure) and not download_failure.check(IgnoreRequest):
if download_failure.frames:
logger.error('Error downloading %(request)s',
{'request': request},
@@ -224,18 +230,22 @@ class Scraper(object):
ex = output.value
if isinstance(ex, DropItem):
logkws = self.logformatter.dropped(item, ex, response, spider)
- logger.log(*logformatter_adapter(logkws), extra={'spider': spider})
+ if logkws is not None:
+ logger.log(*logformatter_adapter(logkws), extra={'spider': spider})
return self.signals.send_catch_log_deferred(
signal=signals.item_dropped, item=item, response=response,
spider=spider, exception=output.value)
else:
- logger.error('Error processing %(item)s', {'item': item},
- exc_info=failure_to_exc_info(output),
- extra={'spider': spider})
+ logkws = self.logformatter.error(item, ex, response, spider)
+ logger.log(*logformatter_adapter(logkws), extra={'spider': spider},
+ exc_info=failure_to_exc_info(output))
+ return self.signals.send_catch_log_deferred(
+ signal=signals.item_error, item=item, response=response,
+ spider=spider, failure=output)
else:
logkws = self.logformatter.scraped(output, response, spider)
- logger.log(*logformatter_adapter(logkws), extra={'spider': spider})
+ if logkws is not None:
+ logger.log(*logformatter_adapter(logkws), extra={'spider': spider})
return self.signals.send_catch_log_deferred(
signal=signals.item_scraped, item=output, response=response,
spider=spider)
-
diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py
index a206e4b0c..097a374bf 100644
--- a/scrapy/core/spidermw.py
+++ b/scrapy/core/spidermw.py
@@ -3,15 +3,20 @@ Spider Middleware manager
See documentation in docs/topics/spider-middleware.rst
"""
-import six
+from itertools import chain, islice
+
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
+from scrapy.utils.python import MutableChain
+
def _isiterable(possible_iterator):
return hasattr(possible_iterator, '__iter__')
+
class SpiderMiddlewareManager(MiddlewareManager):
component_name = 'spider middleware'
@@ -24,52 +29,87 @@ 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.methods['process_start_requests'].appendleft(mw.process_start_requests)
+ self.methods['process_spider_output'].appendleft(getattr(mw, 'process_spider_output', None))
+ self.methods['process_spider_exception'].appendleft(getattr(mw, 'process_spider_exception', None))
def scrape_response(self, scrape_func, response, request, spider):
- fname = lambda f:'%s.%s' % (
- six.get_method_self(f).__class__.__name__,
- six.get_method_function(f).__name__)
+ fname = lambda f: '%s.%s' % (
+ f.__self__.__class__.__name__,
+ f.__func__.__name__)
def process_spider_input(response):
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))
- except:
+ 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 Exception:
return scrape_func(Failure(), request, spider)
return scrape_func(response, request, spider)
- def process_spider_exception(_failure):
+ def process_spider_exception(_failure, start_index=0):
exception = _failure.value
- for method in self.methods['process_spider_exception']:
+ # don't handle _InvalidOutput exception
+ if isinstance(exception, _InvalidOutput):
+ return _failure
+ 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
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:
- return result
+ 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, method_index+1)
+ elif result is None:
+ continue
+ else:
+ raise _InvalidOutput('Middleware {} must return None or an iterable, got {}'
+ .format(fname(method), type(result)))
return _failure
- def process_spider_output(result):
- 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))
- return result
+ 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()
+
+ def evaluate_iterable(iterable, index):
+ try:
+ for r in iterable:
+ yield r
+ except Exception as ex:
+ exception_result = process_spider_exception(Failure(ex), index+1)
+ if isinstance(exception_result, Failure):
+ raise
+ recovered.extend(exception_result)
+
+ 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
+ # 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), method_index+1)
+ if isinstance(exception_result, Failure):
+ raise
+ return exception_result
+ if _isiterable(result):
+ result = evaluate_iterable(result, method_index)
+ else:
+ raise _InvalidOutput('Middleware {} must return an iterable, got {}'
+ .format(fname(method), type(result)))
+
+ return chain(result, recovered)
dfd = mustbe_deferred(process_spider_input, response)
- dfd.addErrback(process_spider_exception)
- dfd.addCallback(process_spider_output)
+ dfd.addCallbacks(callback=process_spider_output, errback=process_spider_exception)
return dfd
def process_start_requests(self, start_requests, spider):
diff --git a/scrapy/crawler.py b/scrapy/crawler.py
index d05c05d28..54fba4ac1 100644
--- a/scrapy/crawler.py
+++ b/scrapy/crawler.py
@@ -1,32 +1,39 @@
-import six
-import signal
import logging
+import pprint
+import signal
import warnings
-import sys
-from twisted.internet import reactor, defer
-from zope.interface.verify import verifyClass, DoesNotImplement
+from twisted.internet import defer
+from zope.interface.verify import DoesNotImplement, verifyClass
+from scrapy import signals, Spider
from scrapy.core.engine import ExecutionEngine
-from scrapy.resolver import CachingThreadedResolver
-from scrapy.interfaces import ISpiderLoader
+from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.extension import ExtensionManager
+from scrapy.interfaces import ISpiderLoader
from scrapy.settings import overridden_settings, Settings
from scrapy.signalmanager import SignalManager
-from scrapy.exceptions import ScrapyDeprecationWarning
-from scrapy.utils.ossignal import install_shutdown_handlers, signal_names
-from scrapy.utils.misc import load_object
+from scrapy.utils.asyncio import install_asyncio_reactor, is_asyncio_reactor_installed
from scrapy.utils.log import (
- LogCounterHandler, configure_logging, log_scrapy_info,
- get_scrapy_root_handler, install_scrapy_root_handler)
-from scrapy import signals
+ configure_logging,
+ get_scrapy_root_handler,
+ install_scrapy_root_handler,
+ log_scrapy_info,
+ LogCounterHandler,
+)
+from scrapy.utils.misc import create_instance, load_object
+from scrapy.utils.ossignal import install_shutdown_handlers, signal_names
+
logger = logging.getLogger(__name__)
-class Crawler(object):
+class Crawler:
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)
@@ -34,14 +41,16 @@ class Crawler(object):
self.settings = settings.copy()
self.spidercls.update_settings(self.settings)
- d = dict(overridden_settings(self.settings))
- logger.info("Overridden settings: %(settings)r", {'settings': d})
-
self.signals = SignalManager(self)
self.stats = load_object(self.settings['STATS_CLASS'])(self)
handler = LogCounterHandler(self, level=self.settings.get('LOG_LEVEL'))
logging.root.addHandler(handler)
+
+ d = dict(overridden_settings(self.settings))
+ logger.info("Overridden settings:\n%(settings)s",
+ {'settings': pprint.pformat(d)})
+
if get_scrapy_root_handler() is not None:
# scrapy root handler already installed: update it with new settings
install_scrapy_root_handler(self.settings)
@@ -82,20 +91,9 @@ class Crawler(object):
yield self.engine.open_spider(self.spider, start_requests)
yield defer.maybeDeferred(self.engine.start)
except Exception:
- # In Python 2 reraising an exception after yield discards
- # the original traceback (see https://bugs.python.org/issue7563),
- # so sys.exc_info() workaround is used.
- # This workaround also works in Python 3, but it is not needed,
- # and it is slower, so in Python 3 we use native `raise`.
- if six.PY2:
- exc_info = sys.exc_info()
-
self.crawling = False
if self.engine is not None:
yield self.engine.close()
-
- if six.PY2:
- six.reraise(*exc_info)
raise
def _create_spider(self, *args, **kwargs):
@@ -106,15 +104,17 @@ class Crawler(object):
@defer.inlineCallbacks
def stop(self):
+ """Starts a graceful stop of the crawler and returns a deferred that is
+ fired when the crawler is stopped."""
if self.crawling:
self.crawling = False
yield defer.maybeDeferred(self.engine.stop)
-class CrawlerRunner(object):
+class CrawlerRunner:
"""
This is a convenient helper class that keeps track of, manages and runs
- crawlers inside an already setup Twisted `reactor`_.
+ crawlers inside an already setup :mod:`~twisted.internet.reactor`.
The CrawlerRunner object must be instantiated with a
:class:`~scrapy.settings.Settings` object.
@@ -137,6 +137,8 @@ class CrawlerRunner(object):
self.spider_loader = _get_spider_loader(settings)
self._crawlers = set()
self._active = set()
+ self.bootstrap_failed = False
+ self._handle_asyncio_reactor()
@property
def spiders(self):
@@ -152,7 +154,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.
@@ -167,6 +169,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)
@@ -178,6 +184,7 @@ class CrawlerRunner(object):
def _done(result):
self.crawlers.discard(crawler)
self._active.discard(d)
+ self.bootstrap_failed |= not getattr(crawler, 'spider', None)
return result
return d.addBoth(_done)
@@ -186,19 +193,23 @@ 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.
"""
+ 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)
def _create_crawler(self, spidercls):
- if isinstance(spidercls, six.string_types):
+ if isinstance(spidercls, str):
spidercls = self.spider_loader.load(spidercls)
return Crawler(spidercls, self.settings)
@@ -221,18 +232,24 @@ class CrawlerRunner(object):
while self._active:
yield defer.DeferredList(self._active)
+ def _handle_asyncio_reactor(self):
+ if self.settings.getbool('ASYNCIO_REACTOR') and not is_asyncio_reactor_installed():
+ raise Exception("ASYNCIO_REACTOR is on but the Twisted asyncio "
+ "reactor is not installed.")
+
class CrawlerProcess(CrawlerRunner):
"""
A class to run multiple scrapy crawlers in a process simultaneously.
This class extends :class:`~scrapy.crawler.CrawlerRunner` by adding support
- for starting a Twisted `reactor`_ and handling shutdown signals, like the
- keyboard interrupt command Ctrl-C. It also configures top-level logging.
+ for starting a :mod:`~twisted.internet.reactor` and handling shutdown
+ signals, like the keyboard interrupt command Ctrl-C. It also configures
+ top-level logging.
This utility should be a better fit than
:class:`~scrapy.crawler.CrawlerRunner` if you aren't running another
- Twisted `reactor`_ within your application.
+ :mod:`~twisted.internet.reactor` within your application.
The CrawlerProcess object must be instantiated with a
:class:`~scrapy.settings.Settings` object.
@@ -252,6 +269,7 @@ class CrawlerProcess(CrawlerRunner):
log_scrapy_info(self.settings)
def _signal_shutdown(self, signum, _):
+ from twisted.internet import reactor
install_shutdown_handlers(self._signal_kill)
signame = signal_names[signum]
logger.info("Received %(signame)s, shutting down gracefully. Send again to force ",
@@ -259,6 +277,7 @@ class CrawlerProcess(CrawlerRunner):
reactor.callFromThread(self._graceful_stop_reactor)
def _signal_kill(self, signum, _):
+ from twisted.internet import reactor
install_shutdown_handlers(signal.SIG_IGN)
signame = signal_names[signum]
logger.info('Received %(signame)s twice, forcing unclean shutdown',
@@ -267,16 +286,17 @@ class CrawlerProcess(CrawlerRunner):
def start(self, stop_after_crawl=True):
"""
- This method starts a Twisted `reactor`_, adjusts its pool size to
- :setting:`REACTOR_THREADPOOL_MAXSIZE`, and installs a DNS cache based
- on :setting:`DNSCACHE_ENABLED` and :setting:`DNSCACHE_SIZE`.
+ This method starts a :mod:`~twisted.internet.reactor`, adjusts its pool
+ size to :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
crawlers have finished
"""
+ from twisted.internet import reactor
if stop_after_crawl:
d = self.join()
# Don't start the reactor if the deferreds are already fired
@@ -284,45 +304,35 @@ class CrawlerProcess(CrawlerRunner):
return
d.addBoth(self._stop_reactor)
- reactor.installResolver(self._get_dns_resolver())
+ resolver_class = load_object(self.settings["DNS_RESOLVER"])
+ resolver = create_instance(resolver_class, self.settings, self, reactor=reactor)
+ resolver.install_on_reactor()
tp = reactor.getThreadPool()
tp.adjustPoolsize(maxthreads=self.settings.getint('REACTOR_THREADPOOL_MAXSIZE'))
reactor.addSystemEventTrigger('before', 'shutdown', self.stop)
reactor.run(installSignalHandlers=False) # blocking call
- def _get_dns_resolver(self):
- if self.settings.getbool('DNSCACHE_ENABLED'):
- cache_size = self.settings.getint('DNSCACHE_SIZE')
- else:
- cache_size = 0
- return CachingThreadedResolver(
- reactor=reactor,
- cache_size=cache_size,
- timeout=self.settings.getfloat('DNS_TIMEOUT')
- )
-
def _graceful_stop_reactor(self):
d = self.stop()
d.addBoth(self._stop_reactor)
return d
def _stop_reactor(self, _=None):
+ from twisted.internet import reactor
try:
reactor.stop()
except RuntimeError: # raised if already stopped or in shutdown stage
pass
+ def _handle_asyncio_reactor(self):
+ if self.settings.getbool('ASYNCIO_REACTOR'):
+ install_asyncio_reactor()
+ super()._handle_asyncio_reactor()
+
def _get_spider_loader(settings):
""" Get SpiderLoader instance from settings """
- if settings.get('SPIDER_MANAGER_CLASS'):
- warnings.warn(
- 'SPIDER_MANAGER_CLASS option is deprecated. '
- 'Please use SPIDER_LOADER_CLASS.',
- category=ScrapyDeprecationWarning, stacklevel=2
- )
- cls_path = settings.get('SPIDER_MANAGER_CLASS',
- settings.get('SPIDER_LOADER_CLASS'))
+ cls_path = settings.get('SPIDER_LOADER_CLASS')
loader_cls = load_object(cls_path)
try:
verifyClass(ISpiderLoader, loader_cls)
diff --git a/scrapy/downloadermiddlewares/ajaxcrawl.py b/scrapy/downloadermiddlewares/ajaxcrawl.py
index 72715dba7..7a140fcad 100644
--- a/scrapy/downloadermiddlewares/ajaxcrawl.py
+++ b/scrapy/downloadermiddlewares/ajaxcrawl.py
@@ -1,9 +1,7 @@
# -*- coding: utf-8 -*-
-from __future__ import absolute_import
import re
import logging
-import six
from w3lib import html
from scrapy.exceptions import NotConfigured
@@ -67,7 +65,9 @@ class AjaxCrawlMiddleware(object):
# XXX: move it to w3lib?
-_ajax_crawlable_re = re.compile(six.u(r' '))
+_ajax_crawlable_re = re.compile(r' ')
+
+
def _has_ajaxcrawlable_meta(text):
"""
>>> _has_ajaxcrawlable_meta(' ')
diff --git a/scrapy/downloadermiddlewares/cookies.py b/scrapy/downloadermiddlewares/cookies.py
index 321c0171b..d8dabdf13 100644
--- a/scrapy/downloadermiddlewares/cookies.py
+++ b/scrapy/downloadermiddlewares/cookies.py
@@ -1,12 +1,11 @@
-import os
-import six
import logging
from collections import defaultdict
from scrapy.exceptions import NotConfigured
from scrapy.http import Response
from scrapy.http.cookies import CookieJar
-from scrapy.utils.python import to_native_str
+from scrapy.utils.python import to_unicode
+
logger = logging.getLogger(__name__)
@@ -53,7 +52,7 @@ class CookiesMiddleware(object):
def _debug_cookie(self, request, spider):
if self.debug:
- cl = [to_native_str(c, errors='replace')
+ cl = [to_unicode(c, errors='replace')
for c in request.headers.getlist('Cookie')]
if cl:
cookies = "\n".join("Cookie: {}\n".format(c) for c in cl)
@@ -62,7 +61,7 @@ class CookiesMiddleware(object):
def _debug_set_cookie(self, response, spider):
if self.debug:
- cl = [to_native_str(c, errors='replace')
+ cl = [to_unicode(c, errors='replace')
for c in response.headers.getlist('Set-Cookie')]
if cl:
cookies = "\n".join("Set-Cookie: {}\n".format(c) for c in cl)
@@ -82,8 +81,10 @@ class CookiesMiddleware(object):
def _get_request_cookies(self, jar, request):
if isinstance(request.cookies, dict):
- cookie_list = [{'name': k, 'value': v} for k, v in \
- six.iteritems(request.cookies)]
+ cookie_list = [
+ {'name': k, 'value': v}
+ for k, v in request.cookies.items()
+ ]
else:
cookie_list = request.cookies
diff --git a/scrapy/downloadermiddlewares/decompression.py b/scrapy/downloadermiddlewares/decompression.py
index 49313cc04..fcea38ef5 100644
--- a/scrapy/downloadermiddlewares/decompression.py
+++ b/scrapy/downloadermiddlewares/decompression.py
@@ -4,20 +4,15 @@ and extract the potentially compressed responses that may arrive.
import bz2
import gzip
-import zipfile
-import tarfile
import logging
+import tarfile
+import zipfile
+from io import BytesIO
from tempfile import mktemp
-import six
-
-try:
- from cStringIO import StringIO as BytesIO
-except ImportError:
- from io import BytesIO
-
from scrapy.responsetypes import responsetypes
+
logger = logging.getLogger(__name__)
@@ -79,7 +74,7 @@ class DecompressionMiddleware(object):
if not response.body:
return response
- for fmt, func in six.iteritems(self._formats):
+ for fmt, func in self._formats.items():
new_response = func(response)
if new_response:
logger.debug('Decompressed response with format: %(responsefmt)s',
diff --git a/scrapy/downloadermiddlewares/httpcache.py b/scrapy/downloadermiddlewares/httpcache.py
index 495b103d1..4e06f8236 100644
--- a/scrapy/downloadermiddlewares/httpcache.py
+++ b/scrapy/downloadermiddlewares/httpcache.py
@@ -1,11 +1,19 @@
from email.utils import formatdate
+
from twisted.internet import defer
-from twisted.internet.error import TimeoutError, DNSLookupError, \
- ConnectionRefusedError, ConnectionDone, ConnectError, \
- ConnectionLost, TCPTimedOutError
+from twisted.internet.error import (
+ ConnectError,
+ ConnectionDone,
+ ConnectionLost,
+ ConnectionRefusedError,
+ DNSLookupError,
+ TCPTimedOutError,
+ TimeoutError,
+)
from twisted.web.client import ResponseFailed
+
from scrapy import signals
-from scrapy.exceptions import NotConfigured, IgnoreRequest
+from scrapy.exceptions import IgnoreRequest, NotConfigured
from scrapy.utils.misc import load_object
diff --git a/scrapy/downloadermiddlewares/httpcompression.py b/scrapy/downloadermiddlewares/httpcompression.py
index 203dee42d..0010b2a8f 100644
--- a/scrapy/downloadermiddlewares/httpcompression.py
+++ b/scrapy/downloadermiddlewares/httpcompression.py
@@ -26,7 +26,7 @@ class HttpCompressionMiddleware(object):
def process_request(self, request, spider):
request.headers.setdefault('Accept-Encoding',
- b",".join(ACCEPTED_ENCODINGS))
+ b", ".join(ACCEPTED_ENCODINGS))
def process_response(self, request, response, spider):
@@ -37,8 +37,9 @@ class HttpCompressionMiddleware(object):
if content_encoding:
encoding = content_encoding.pop()
decoded_body = self._decode(response.body, encoding.lower())
- respcls = responsetypes.from_args(headers=response.headers, \
- url=response.url, body=decoded_body)
+ respcls = responsetypes.from_args(
+ headers=response.headers, url=response.url, body=decoded_body
+ )
kwargs = dict(cls=respcls, body=decoded_body)
if issubclass(respcls, TextResponse):
# force recalculating the encoding until we make sure the
diff --git a/scrapy/downloadermiddlewares/httpproxy.py b/scrapy/downloadermiddlewares/httpproxy.py
index 0d5320bf8..814ce78fe 100644
--- a/scrapy/downloadermiddlewares/httpproxy.py
+++ b/scrapy/downloadermiddlewares/httpproxy.py
@@ -1,14 +1,9 @@
import base64
-from six.moves.urllib.request import getproxies, proxy_bypass
-from six.moves.urllib.parse import unquote
-try:
- from urllib2 import _parse_proxy
-except ImportError:
- from urllib.request import _parse_proxy
-from six.moves.urllib.parse import urlunparse
+from urllib.parse import unquote, urlunparse
+from urllib.request import getproxies, proxy_bypass, _parse_proxy
-from scrapy.utils.httpobj import urlparse_cached
from scrapy.exceptions import NotConfigured
+from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.python import to_bytes
@@ -17,8 +12,8 @@ class HttpProxyMiddleware(object):
def __init__(self, auth_encoding='latin-1'):
self.auth_encoding = auth_encoding
self.proxies = {}
- for type, url in getproxies().items():
- self.proxies[type] = self._get_proxy(url, type)
+ for type_, url in getproxies().items():
+ self.proxies[type_] = self._get_proxy(url, type_)
@classmethod
def from_crawler(cls, crawler):
@@ -31,7 +26,7 @@ class HttpProxyMiddleware(object):
user_pass = to_bytes(
'%s:%s' % (unquote(username), unquote(password)),
encoding=self.auth_encoding)
- return base64.b64encode(user_pass).strip()
+ return base64.b64encode(user_pass)
def _get_proxy(self, url, orig_type):
proxy_type, user, password, hostport = _parse_proxy(url)
diff --git a/scrapy/downloadermiddlewares/redirect.py b/scrapy/downloadermiddlewares/redirect.py
index 30cae3fee..77cb5aa94 100644
--- a/scrapy/downloadermiddlewares/redirect.py
+++ b/scrapy/downloadermiddlewares/redirect.py
@@ -1,5 +1,5 @@
import logging
-from six.moves.urllib.parse import urljoin
+from urllib.parse import urljoin, urlparse
from w3lib.url import safe_url_string
@@ -7,6 +7,7 @@ from scrapy.http import HtmlResponse
from scrapy.utils.response import get_meta_refresh
from scrapy.exceptions import IgnoreRequest, NotConfigured
+
logger = logging.getLogger(__name__)
@@ -34,6 +35,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",
@@ -68,7 +71,10 @@ class RedirectMiddleware(BaseRedirectMiddleware):
if 'Location' not in response.headers or response.status not in allowed_status:
return response
- location = safe_url_string(response.headers['location'])
+ location = safe_url_string(response.headers['Location'])
+ if response.headers['Location'].startswith(b'//'):
+ request_scheme = urlparse(request.url).scheme
+ location = request_scheme + '://' + location.lstrip('/')
redirected_url = urljoin(request.url, location)
@@ -86,6 +92,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'))
@@ -94,7 +101,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/downloadermiddlewares/retry.py b/scrapy/downloadermiddlewares/retry.py
index 07e979628..7ab5b6e62 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
@@ -86,6 +84,6 @@ class RetryMiddleware(object):
return retryreq
else:
stats.inc_value('retry/max_reached')
- logger.debug("Gave up retrying %(request)s (failed %(retries)d times): %(reason)s",
+ logger.error("Gave up retrying %(request)s (failed %(retries)d times): %(reason)s",
{'request': request, 'retries': retries, 'reason': reason},
extra={'spider': spider})
diff --git a/scrapy/downloadermiddlewares/robotstxt.py b/scrapy/downloadermiddlewares/robotstxt.py
index 200245210..251706c50 100644
--- a/scrapy/downloadermiddlewares/robotstxt.py
+++ b/scrapy/downloadermiddlewares/robotstxt.py
@@ -6,14 +6,12 @@ enable this middleware and enable the ROBOTSTXT_OBEY setting.
import logging
-from six.moves.urllib import robotparser
-
from twisted.internet.defer import Deferred, maybeDeferred
from scrapy.exceptions import NotConfigured, IgnoreRequest
from scrapy.http import Request
from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.log import failure_to_exc_info
-from scrapy.utils.python import to_native_str
+from scrapy.utils.misc import load_object
logger = logging.getLogger(__name__)
@@ -24,10 +22,14 @@ class RobotsTxtMiddleware(object):
def __init__(self, crawler):
if not crawler.settings.getbool('ROBOTSTXT_OBEY'):
raise NotConfigured
-
+ self._default_useragent = crawler.settings.get('USER_AGENT', 'Scrapy')
+ self._robotstxt_useragent = crawler.settings.get('ROBOTSTXT_USER_AGENT', None)
self.crawler = crawler
- self._useragent = crawler.settings.get('USER_AGENT')
self._parsers = {}
+ self._parserimpl = load_object(crawler.settings.get('ROBOTSTXT_PARSER'))
+
+ # check if parser dependencies are met, this should throw an error otherwise.
+ self._parserimpl.from_crawler(self.crawler, b'')
@classmethod
def from_crawler(cls, crawler):
@@ -43,7 +45,11 @@ class RobotsTxtMiddleware(object):
def process_request_2(self, rp, request, spider):
if rp is None:
return
- if not rp.can_fetch(to_native_str(self._useragent), request.url):
+
+ useragent = self._robotstxt_useragent
+ if not useragent:
+ useragent = request.headers.get(b'User-Agent', self._default_useragent)
+ if not rp.allowed(request.url, useragent):
logger.debug("Forbidden by robots.txt: %(request)s",
{'request': request}, extra={'spider': spider})
self.crawler.stats.inc_value('robotstxt/forbidden')
@@ -62,13 +68,14 @@ class RobotsTxtMiddleware(object):
meta={'dont_obey_robotstxt': True}
)
dfd = self.crawler.engine.download(robotsreq, spider)
- dfd.addCallback(self._parse_robots, netloc)
+ dfd.addCallback(self._parse_robots, netloc, spider)
dfd.addErrback(self._logerror, robotsreq, spider)
dfd.addErrback(self._robots_error, netloc)
self.crawler.stats.inc_value('robotstxt/request_count')
if isinstance(self._parsers[netloc], Deferred):
d = Deferred()
+
def cb(result):
d.callback(result)
return result
@@ -85,27 +92,10 @@ class RobotsTxtMiddleware(object):
extra={'spider': spider})
return failure
- def _parse_robots(self, response, netloc):
+ def _parse_robots(self, response, netloc, spider):
self.crawler.stats.inc_value('robotstxt/response_count')
- self.crawler.stats.inc_value(
- 'robotstxt/response_status_count/{}'.format(response.status))
- rp = robotparser.RobotFileParser(response.url)
- body = ''
- if hasattr(response, 'text'):
- body = response.text
- else: # last effort try
- try:
- body = response.body.decode('utf-8')
- except UnicodeDecodeError:
- # If we found garbage, disregard it:,
- # but keep the lookup cached (in self._parsers)
- # Running rp.parse() will set rp state from
- # 'disallow all' to 'allow any'.
- self.crawler.stats.inc_value('robotstxt/unicode_error_count')
- # stdlib's robotparser expects native 'str' ;
- # with unicode input, non-ASCII encoded bytes decoding fails in Python2
- rp.parse(to_native_str(body).splitlines())
-
+ self.crawler.stats.inc_value('robotstxt/response_status_count/{}'.format(response.status))
+ rp = self._parserimpl.from_crawler(self.crawler, response.body)
rp_dfd = self._parsers[netloc]
self._parsers[netloc] = rp
rp_dfd.callback(rp)
diff --git a/scrapy/dupefilter.py b/scrapy/dupefilter.py
deleted file mode 100644
index 232d96288..000000000
--- a/scrapy/dupefilter.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import warnings
-from scrapy.exceptions import ScrapyDeprecationWarning
-warnings.warn("Module `scrapy.dupefilter` is deprecated, "
- "use `scrapy.dupefilters` instead",
- ScrapyDeprecationWarning, stacklevel=2)
-
-from scrapy.dupefilters import *
diff --git a/scrapy/dupefilters.py b/scrapy/dupefilters.py
index 9d8966b9c..ea6a4cfc3 100644
--- a/scrapy/dupefilters.py
+++ b/scrapy/dupefilters.py
@@ -1,9 +1,8 @@
-from __future__ import print_function
import os
import logging
from scrapy.utils.job import job_dir
-from scrapy.utils.request import request_fingerprint
+from scrapy.utils.request import referer_str, request_fingerprint
class BaseDupeFilter(object):
@@ -61,8 +60,9 @@ class RFPDupeFilter(BaseDupeFilter):
def log(self, request, spider):
if self.debug:
- msg = "Filtered duplicate request: %(request)s"
- self.logger.debug(msg, {'request': request}, extra={'spider': spider})
+ msg = "Filtered duplicate request: %(request)s (referer: %(referer)s)"
+ args = {'request': request, 'referer': referer_str(request) }
+ self.logger.debug(msg, args, extra={'spider': spider})
elif self.logdupes:
msg = ("Filtered duplicate request: %(request)s"
" - no more duplicates will be shown"
diff --git a/scrapy/exceptions.py b/scrapy/exceptions.py
index 4bcecd994..7c4bb3d00 100644
--- a/scrapy/exceptions.py
+++ b/scrapy/exceptions.py
@@ -7,19 +7,32 @@ new exceptions here without documenting them there.
# Internal
+
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.
+ Internal and undocumented, it should not be raised or caught by user code.
+ """
+ pass
+
+
# HTTP and crawling
+
class IgnoreRequest(Exception):
"""Indicates a decision was made not to process a request"""
+
class DontCloseSpider(Exception):
"""Request the spider not to be closed yet"""
pass
+
class CloseSpider(Exception):
"""Raise this from callbacks to request the spider to be closed"""
@@ -27,30 +40,37 @@ class CloseSpider(Exception):
super(CloseSpider, self).__init__()
self.reason = reason
+
# Items
+
class DropItem(Exception):
"""Drop item from the item pipeline"""
pass
+
class NotSupported(Exception):
"""Indicates a feature or method is not supported"""
pass
+
# Commands
+
class UsageError(Exception):
"""To indicate a command-line usage error"""
def __init__(self, *a, **kw):
self.print_help = kw.pop('print_help', True)
super(UsageError, self).__init__(*a, **kw)
+
class ScrapyDeprecationWarning(Warning):
"""Warning category for deprecated features, since the default
DeprecationWarning is silenced on Python 2.7+
"""
pass
+
class ContractFail(AssertionError):
"""Error raised in case of a failing contract"""
pass
diff --git a/scrapy/exporters.py b/scrapy/exporters.py
index 07f43b494..1a3c9345f 100644
--- a/scrapy/exporters.py
+++ b/scrapy/exporters.py
@@ -4,18 +4,16 @@ Item Exporters are used to export/serialize items into different formats.
import csv
import io
-import sys
import pprint
import marshal
-import six
-from six.moves import cPickle as pickle
+import warnings
+import pickle
from xml.sax.saxutils import XMLGenerator
from scrapy.utils.serialize import ScrapyJSONEncoder
-from scrapy.utils.python import to_bytes, to_unicode, to_native_str, is_listlike
+from scrapy.utils.python import to_bytes, to_unicode, is_listlike
from scrapy.item import BaseItem
from scrapy.exceptions import ScrapyDeprecationWarning
-import warnings
__all__ = ['BaseItemExporter', 'PprintItemExporter', 'PickleItemExporter',
@@ -25,13 +23,14 @@ __all__ = ['BaseItemExporter', 'PprintItemExporter', 'PickleItemExporter',
class BaseItemExporter(object):
- def __init__(self, **kwargs):
- self._configure(kwargs)
+ def __init__(self, dont_fail=False, **kwargs):
+ self._kwargs = kwargs
+ self._configure(kwargs, dont_fail=dont_fail)
def _configure(self, options, dont_fail=False):
"""Configure the exporter by poping options from the ``options`` dict.
If dont_fail is set, it won't raise an exception on unexpected options
- (useful for using with keyword arguments in subclasses constructors)
+ (useful for using with keyword arguments in subclasses ``__init__`` methods)
"""
self.encoding = options.pop('encoding', None)
self.fields_to_export = options.pop('fields_to_export', None)
@@ -61,9 +60,9 @@ class BaseItemExporter(object):
include_empty = self.export_empty_fields
if self.fields_to_export is None:
if include_empty and not isinstance(item, dict):
- field_iter = six.iterkeys(item.fields)
+ field_iter = item.fields.keys()
else:
- field_iter = six.iterkeys(item)
+ field_iter = item.keys()
else:
if include_empty:
field_iter = self.fields_to_export
@@ -83,10 +82,10 @@ class BaseItemExporter(object):
class JsonLinesItemExporter(BaseItemExporter):
def __init__(self, file, **kwargs):
- self._configure(kwargs, dont_fail=True)
+ super().__init__(dont_fail=True, **kwargs)
self.file = file
- kwargs.setdefault('ensure_ascii', not self.encoding)
- self.encoder = ScrapyJSONEncoder(**kwargs)
+ self._kwargs.setdefault('ensure_ascii', not self.encoding)
+ self.encoder = ScrapyJSONEncoder(**self._kwargs)
def export_item(self, item):
itemdict = dict(self._get_serialized_fields(item))
@@ -97,15 +96,15 @@ class JsonLinesItemExporter(BaseItemExporter):
class JsonItemExporter(BaseItemExporter):
def __init__(self, file, **kwargs):
- self._configure(kwargs, dont_fail=True)
+ super().__init__(dont_fail=True, **kwargs)
self.file = file
# there is a small difference between the behaviour or JsonItemExporter.indent
# and ScrapyJSONEncoder.indent. ScrapyJSONEncoder.indent=None is needed to prevent
# the addition of newlines everywhere
json_indent = self.indent if self.indent is not None and self.indent > 0 else None
- kwargs.setdefault('indent', json_indent)
- kwargs.setdefault('ensure_ascii', not self.encoding)
- self.encoder = ScrapyJSONEncoder(**kwargs)
+ self._kwargs.setdefault('indent', json_indent)
+ self._kwargs.setdefault('ensure_ascii', not self.encoding)
+ self.encoder = ScrapyJSONEncoder(**self._kwargs)
self.first_item = True
def _beautify_newline(self):
@@ -136,18 +135,18 @@ class XmlItemExporter(BaseItemExporter):
def __init__(self, file, **kwargs):
self.item_element = kwargs.pop('item_element', 'item')
self.root_element = kwargs.pop('root_element', 'items')
- self._configure(kwargs)
+ super().__init__(**kwargs)
if not self.encoding:
self.encoding = 'utf-8'
self.xg = XMLGenerator(file, encoding=self.encoding)
def _beautify_newline(self, new_item=False):
if self.indent is not None and (self.indent > 0 or new_item):
- self._xg_characters('\n')
+ self.xg.characters('\n')
def _beautify_indent(self, depth=1):
if self.indent:
- self._xg_characters(' ' * self.indent * depth)
+ self.xg.characters(' ' * self.indent * depth)
def start_exporting(self):
self.xg.startDocument()
@@ -181,32 +180,18 @@ class XmlItemExporter(BaseItemExporter):
for value in serialized_value:
self._export_xml_field('value', value, depth=depth+1)
self._beautify_indent(depth=depth)
- elif isinstance(serialized_value, six.text_type):
- self._xg_characters(serialized_value)
+ elif isinstance(serialized_value, str):
+ self.xg.characters(serialized_value)
else:
- self._xg_characters(str(serialized_value))
+ self.xg.characters(str(serialized_value))
self.xg.endElement(name)
self._beautify_newline()
- # Workaround for https://bugs.python.org/issue17606
- # Before Python 2.7.4 xml.sax.saxutils required bytes;
- # since 2.7.4 it requires unicode. The bug is likely to be
- # fixed in 2.7.6, but 2.7.6 will still support unicode,
- # and Python 3.x will require unicode, so ">= 2.7.4" should be fine.
- if sys.version_info[:3] >= (2, 7, 4):
- def _xg_characters(self, serialized_value):
- if not isinstance(serialized_value, six.text_type):
- serialized_value = serialized_value.decode(self.encoding)
- return self.xg.characters(serialized_value)
- else: # pragma: no cover
- def _xg_characters(self, serialized_value):
- return self.xg.characters(serialized_value)
-
class CsvItemExporter(BaseItemExporter):
def __init__(self, file, include_headers_line=True, join_multivalued=',', **kwargs):
- self._configure(kwargs, dont_fail=True)
+ super().__init__(dont_fail=True, **kwargs)
if not self.encoding:
self.encoding = 'utf-8'
self.include_headers_line = include_headers_line
@@ -214,9 +199,10 @@ class CsvItemExporter(BaseItemExporter):
file,
line_buffering=False,
write_through=True,
- encoding=self.encoding
- ) if six.PY3 else file
- self.csv_writer = csv.writer(self.stream, **kwargs)
+ encoding=self.encoding,
+ newline='' # Windows needs this https://github.com/scrapy/scrapy/issues/3034
+ )
+ self.csv_writer = csv.writer(self.stream, **self._kwargs)
self._headers_not_written = True
self._join_multivalued = join_multivalued
@@ -245,7 +231,7 @@ class CsvItemExporter(BaseItemExporter):
def _build_row(self, values):
for s in values:
try:
- yield to_native_str(s, self.encoding)
+ yield to_unicode(s, self.encoding)
except TypeError:
yield s
@@ -265,7 +251,7 @@ class CsvItemExporter(BaseItemExporter):
class PickleItemExporter(BaseItemExporter):
def __init__(self, file, protocol=2, **kwargs):
- self._configure(kwargs)
+ super().__init__(**kwargs)
self.file = file
self.protocol = protocol
@@ -275,9 +261,16 @@ class PickleItemExporter(BaseItemExporter):
class MarshalItemExporter(BaseItemExporter):
+ """Exports items in a Python-specific binary format (see
+ :mod:`marshal`).
+
+ :param file: The file-like object to use for exporting the data. Its
+ ``write`` method should accept :class:`bytes` (a disk file
+ opened in binary mode, a :class:`~io.BytesIO` object, etc)
+ """
def __init__(self, file, **kwargs):
- self._configure(kwargs)
+ super().__init__(**kwargs)
self.file = file
def export_item(self, item):
@@ -287,7 +280,7 @@ class MarshalItemExporter(BaseItemExporter):
class PprintItemExporter(BaseItemExporter):
def __init__(self, file, **kwargs):
- self._configure(kwargs)
+ super().__init__(**kwargs)
self.file = file
def export_item(self, item):
@@ -296,10 +289,13 @@ class PprintItemExporter(BaseItemExporter):
class PythonItemExporter(BaseItemExporter):
- """The idea behind this exporter is to have a mechanism to serialize items
- to built-in python types so any serialization library (like
- json, msgpack, binc, etc) can be used on top of it. Its main goal is to
- seamless support what BaseItemExporter does plus nested items.
+ """This is a base class for item exporters that extends
+ :class:`BaseItemExporter` with support for nested items.
+
+ It serializes items to built-in Python types, so that any serialization
+ library (e.g. :mod:`json` or msgpack_) can be used on top of it.
+
+ .. _msgpack: https://pypi.org/project/msgpack/
"""
def _configure(self, options, dont_fail=False):
self.binary = options.pop('binary', True)
@@ -323,12 +319,12 @@ class PythonItemExporter(BaseItemExporter):
if is_listlike(value):
return [self._serialize_value(v) for v in value]
encode_func = to_bytes if self.binary else to_unicode
- if isinstance(value, (six.text_type, bytes)):
+ if isinstance(value, (str, bytes)):
return encode_func(value, encoding=self.encoding)
return value
def _serialize_dict(self, value):
- for key, val in six.iteritems(value):
+ for key, val in value.items():
key = to_bytes(key) if self.binary else key
yield key, self._serialize_value(val)
diff --git a/scrapy/extension.py b/scrapy/extension.py
index e39e456fa..050b87e5f 100644
--- a/scrapy/extension.py
+++ b/scrapy/extension.py
@@ -6,6 +6,7 @@ See documentation in docs/topics/extensions.rst
from scrapy.middleware import MiddlewareManager
from scrapy.utils.conf import build_component_list
+
class ExtensionManager(MiddlewareManager):
component_name = 'extension'
diff --git a/scrapy/extensions/closespider.py b/scrapy/extensions/closespider.py
index 9ccf356ec..afb2ed049 100644
--- a/scrapy/extensions/closespider.py
+++ b/scrapy/extensions/closespider.py
@@ -54,9 +54,9 @@ class CloseSpider(object):
self.crawler.engine.close_spider(spider, 'closespider_pagecount')
def spider_opened(self, spider):
- self.task = reactor.callLater(self.close_on['timeout'], \
- self.crawler.engine.close_spider, spider, \
- reason='closespider_timeout')
+ self.task = reactor.callLater(self.close_on['timeout'],
+ self.crawler.engine.close_spider, spider,
+ reason='closespider_timeout')
def item_scraped(self, item, spider):
self.counter['itemcount'] += 1
diff --git a/scrapy/extensions/corestats.py b/scrapy/extensions/corestats.py
index 3d9a307b7..20adfbe4b 100644
--- a/scrapy/extensions/corestats.py
+++ b/scrapy/extensions/corestats.py
@@ -1,14 +1,16 @@
"""
Extension for collecting core stats like items scraped and start/finish times
"""
-import datetime
+from datetime import datetime
from scrapy import signals
+
class CoreStats(object):
def __init__(self, stats):
self.stats = stats
+ self.start_time = None
@classmethod
def from_crawler(cls, crawler):
@@ -21,10 +23,15 @@ class CoreStats(object):
return o
def spider_opened(self, spider):
- self.stats.set_value('start_time', datetime.datetime.utcnow(), spider=spider)
+ self.start_time = datetime.utcnow()
+ self.stats.set_value('start_time', self.start_time, spider=spider)
def spider_closed(self, spider, reason):
- self.stats.set_value('finish_time', datetime.datetime.utcnow(), spider=spider)
+ finish_time = datetime.utcnow()
+ elapsed_time = finish_time - self.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/scrapy/extensions/feedexport.py b/scrapy/extensions/feedexport.py
index 5f133fbde..f1b101780 100644
--- a/scrapy/extensions/feedexport.py
+++ b/scrapy/extensions/feedexport.py
@@ -7,21 +7,18 @@ See documentation in docs/topics/feed-exports.rst
import os
import sys
import logging
-import posixpath
from tempfile import NamedTemporaryFile
from datetime import datetime
-import six
-from six.moves.urllib.parse import urlparse
-from ftplib import FTP
+from urllib.parse import urlparse, unquote
from zope.interface import Interface, implementer
from twisted.internet import defer, threads
from w3lib.url import file_uri_to_path
from scrapy import signals
-from scrapy.utils.ftp import ftp_makedirs_cwd
+from scrapy.utils.ftp import ftp_store_file
from scrapy.exceptions import NotConfigured
-from scrapy.utils.misc import load_object
+from scrapy.utils.misc import create_instance, load_object
from scrapy.utils.log import failure_to_exc_info
from scrapy.utils.python import without_none_values
from scrapy.utils.boto import is_botocore
@@ -65,7 +62,7 @@ class StdoutFeedStorage(object):
def __init__(self, uri, _stdout=None):
if not _stdout:
- _stdout = sys.stdout if six.PY2 else sys.stdout.buffer
+ _stdout = sys.stdout.buffer
self._stdout = _stdout
def open(self, spider):
@@ -93,14 +90,33 @@ class FileFeedStorage(object):
class S3FeedStorage(BlockingFeedStorage):
- def __init__(self, uri):
- from scrapy.conf import settings
+ def __init__(self, uri, access_key=None, secret_key=None, acl=None):
+ # 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:
+ from scrapy.utils.project import get_project_settings
+ settings = get_project_settings()
+ if 'AWS_ACCESS_KEY_ID' in settings or 'AWS_SECRET_ACCESS_KEY' in settings:
+ import warnings
+ from scrapy.exceptions import ScrapyDeprecationWarning
+ warnings.warn(
+ "Initialising `scrapy.extensions.feedexport.S3FeedStorage` "
+ "without AWS keys is deprecated. Please supply credentials or "
+ "use the `from_crawler()` constructor.",
+ category=ScrapyDeprecationWarning,
+ stacklevel=2
+ )
+ access_key = settings['AWS_ACCESS_KEY_ID']
+ secret_key = settings['AWS_SECRET_ACCESS_KEY']
+ # END Backward compatibility
u = urlparse(uri)
self.bucketname = u.hostname
- self.access_key = u.username or settings['AWS_ACCESS_KEY_ID']
- self.secret_key = u.password or settings['AWS_SECRET_ACCESS_KEY']
+ self.access_key = u.username or access_key
+ self.secret_key = u.password or secret_key
self.is_botocore = is_botocore()
self.keyname = u.path[1:] # remove first "/"
+ self.acl = acl
if self.is_botocore:
import botocore.session
session = botocore.session.get_session()
@@ -111,38 +127,55 @@ class S3FeedStorage(BlockingFeedStorage):
import boto
self.connect_s3 = boto.connect_s3
+ @classmethod
+ def from_crawler(cls, crawler, uri):
+ 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'] or None
+ )
+
def _store_in_thread(self, file):
file.seek(0)
if self.is_botocore:
+ kwargs = {'ACL': self.acl} if self.acl else {}
self.s3_client.put_object(
- Bucket=self.bucketname, Key=self.keyname, Body=file)
+ Bucket=self.bucketname, Key=self.keyname, Body=file,
+ **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)
+ kwargs = {'policy': self.acl} if self.acl else {}
+ key.set_contents_from_file(file, **kwargs)
key.close()
class FTPFeedStorage(BlockingFeedStorage):
- def __init__(self, uri):
+ def __init__(self, uri, use_active_mode=False):
u = urlparse(uri)
self.host = u.hostname
self.port = int(u.port or '21')
self.username = u.username
- self.password = u.password
+ self.password = unquote(u.password)
self.path = u.path
+ self.use_active_mode = use_active_mode
+
+ @classmethod
+ def from_crawler(cls, crawler, uri):
+ return cls(
+ uri=uri,
+ use_active_mode=crawler.settings.getbool('FEED_STORAGE_FTP_ACTIVE')
+ )
def _store_in_thread(self, file):
- file.seek(0)
- ftp = FTP()
- ftp.connect(self.host, self.port)
- ftp.login(self.username, self.password)
- dirname, filename = posixpath.split(self.path)
- ftp_makedirs_cwd(ftp, dirname)
- ftp.storbinary('STOR %s' % filename, file)
- ftp.quit()
+ ftp_store_file(
+ path=self.path, file=file, host=self.host,
+ port=self.port, username=self.username,
+ password=self.password, use_active_mode=self.use_active_mode
+ )
class SpiderSlot(object):
@@ -158,9 +191,9 @@ class FeedExporter(object):
def __init__(self, settings):
self.settings = settings
- self.urifmt = settings['FEED_URI']
- if not self.urifmt:
+ if not settings['FEED_URI']:
raise NotConfigured
+ self.urifmt = str(settings['FEED_URI'])
self.format = settings['FEED_FORMAT'].lower()
self.export_encoding = settings['FEED_EXPORT_ENCODING']
self.storages = self._load_components('FEED_STORAGES')
@@ -181,6 +214,7 @@ class FeedExporter(object):
@classmethod
def from_crawler(cls, crawler):
o = cls(crawler.settings)
+ o.crawler = crawler
crawler.signals.connect(o.open_spider, signals.spider_opened)
crawler.signals.connect(o.close_spider, signals.spider_closed)
crawler.signals.connect(o.item_scraped, signals.item_scraped)
@@ -200,7 +234,9 @@ class FeedExporter(object):
def close_spider(self, spider):
slot = self.slot
if not slot.itemcount and not self.store_empty:
- return
+ # We need to call slot.storage.store nonetheless to get the file
+ # properly closed.
+ return defer.maybeDeferred(slot.storage.store, slot.file)
if self._exporting:
slot.exporter.finish_exporting()
self._exporting = False
@@ -246,18 +282,24 @@ class FeedExporter(object):
try:
self._get_storage(uri)
return True
- except NotConfigured:
- logger.error("Disabled feed storage scheme: %(scheme)s",
- {'scheme': scheme})
+ except NotConfigured as e:
+ logger.error("Disabled feed storage scheme: %(scheme)s. "
+ "Reason: %(reason)s",
+ {'scheme': scheme, 'reason': str(e)})
else:
logger.error("Unknown feed storage scheme: %(scheme)s",
{'scheme': scheme})
+ def _get_instance(self, objcls, *args, **kwargs):
+ return create_instance(
+ objcls, self.settings, getattr(self, 'crawler', None),
+ *args, **kwargs)
+
def _get_exporter(self, *args, **kwargs):
- return self.exporters[self.format](*args, **kwargs)
+ return self._get_instance(self.exporters[self.format], *args, **kwargs)
def _get_storage(self, uri):
- return self.storages[urlparse(uri).scheme](uri)
+ return self._get_instance(self.storages[urlparse(uri).scheme], uri)
def _get_uri_params(self, spider):
params = {}
diff --git a/scrapy/extensions/httpcache.py b/scrapy/extensions/httpcache.py
index 1b5e05b1b..91850683f 100644
--- a/scrapy/extensions/httpcache.py
+++ b/scrapy/extensions/httpcache.py
@@ -1,19 +1,20 @@
-from __future__ import print_function
-import os
import gzip
import logging
-from six.moves import cPickle as pickle
+import os
+import pickle
+from email.utils import mktime_tz, parsedate_tz
from importlib import import_module
from time import time
from weakref import WeakKeyDictionary
-from email.utils import mktime_tz, parsedate_tz
+
from w3lib.http import headers_raw_to_dict, headers_dict_to_raw
+
from scrapy.http import Headers, Response
from scrapy.responsetypes import responsetypes
-from scrapy.utils.request import request_fingerprint
-from scrapy.utils.project import data_path
from scrapy.utils.httpobj import urlparse_cached
-from scrapy.utils.python import to_bytes, to_unicode, garbage_collect
+from scrapy.utils.project import data_path
+from scrapy.utils.python import to_bytes, to_unicode
+from scrapy.utils.request import request_fingerprint
logger = logging.getLogger(__name__)
@@ -31,7 +32,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):
@@ -70,7 +71,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)
@@ -342,75 +343,6 @@ class FilesystemCacheStorage(object):
return pickle.load(f)
-class LeveldbCacheStorage(object):
-
- def __init__(self, settings):
- import leveldb
- self._leveldb = leveldb
- self.cachedir = data_path(settings['HTTPCACHE_DIR'], createdir=True)
- self.expiration_secs = settings.getint('HTTPCACHE_EXPIRATION_SECS')
- self.db = None
-
- def open_spider(self, spider):
- dbpath = os.path.join(self.cachedir, '%s.leveldb' % spider.name)
- self.db = self._leveldb.LevelDB(dbpath)
-
- logger.debug("Using LevelDB cache storage in %(cachepath)s" % {'cachepath': dbpath}, extra={'spider': spider})
-
- def close_spider(self, spider):
- # Do compactation each time to save space and also recreate files to
- # avoid them being removed in storages with timestamp-based autoremoval.
- self.db.CompactRange()
- del self.db
- garbage_collect()
-
- def retrieve_response(self, spider, request):
- data = self._read_data(spider, request)
- if data is None:
- return # not cached
- url = data['url']
- status = data['status']
- headers = Headers(data['headers'])
- body = data['body']
- respcls = responsetypes.from_args(headers=headers, url=url)
- response = respcls(url=url, headers=headers, status=status, body=body)
- return response
-
- def store_response(self, spider, request, response):
- key = self._request_key(request)
- data = {
- 'status': response.status,
- 'url': response.url,
- 'headers': dict(response.headers),
- 'body': response.body,
- }
- batch = self._leveldb.WriteBatch()
- batch.Put(key + b'_data', pickle.dumps(data, protocol=2))
- batch.Put(key + b'_time', to_bytes(str(time())))
- self.db.Write(batch)
-
- def _read_data(self, spider, request):
- key = self._request_key(request)
- try:
- ts = self.db.Get(key + b'_time')
- except KeyError:
- return # not found or invalid entry
-
- if 0 < self.expiration_secs < time() - float(ts):
- return # expired
-
- try:
- data = self.db.Get(key + b'_data')
- except KeyError:
- return # invalid entry
- else:
- return pickle.loads(data)
-
- def _request_key(self, request):
- return to_bytes(request_fingerprint(request))
-
-
-
def parse_cachecontrol(header):
"""Parse Cache-Control header
diff --git a/scrapy/extensions/memdebug.py b/scrapy/extensions/memdebug.py
index 263d8ce4c..892aa8a86 100644
--- a/scrapy/extensions/memdebug.py
+++ b/scrapy/extensions/memdebug.py
@@ -5,7 +5,6 @@ See documentation in docs/topics/extensions.rst
"""
import gc
-import six
from scrapy import signals
from scrapy.exceptions import NotConfigured
@@ -28,7 +27,7 @@ class MemoryDebugger(object):
def spider_closed(self, spider, reason):
gc.collect()
self.stats.set_value('memdebug/gc_garbage_count', len(gc.garbage), spider=spider)
- for cls, wdict in six.iteritems(live_refs):
+ for cls, wdict in live_refs.items():
if not wdict:
continue
self.stats.set_value('memdebug/live_refs/%s' % cls.__name__, len(wdict), spider=spider)
diff --git a/scrapy/extensions/spiderstate.py b/scrapy/extensions/spiderstate.py
index 2220cbd8f..2c8e46914 100644
--- a/scrapy/extensions/spiderstate.py
+++ b/scrapy/extensions/spiderstate.py
@@ -1,10 +1,11 @@
import os
-from six.moves import cPickle as pickle
+import pickle
from scrapy import signals
from scrapy.exceptions import NotConfigured
from scrapy.utils.job import job_dir
+
class SpiderState(object):
"""Store and load spider state during a scraping job"""
diff --git a/scrapy/extensions/telnet.py b/scrapy/extensions/telnet.py
index e78afa1fc..26b214ee2 100644
--- a/scrapy/extensions/telnet.py
+++ b/scrapy/extensions/telnet.py
@@ -6,13 +6,17 @@ See documentation in docs/topics/telnetconsole.rst
import pprint
import logging
+import traceback
+import binascii
+import os
from twisted.internet import protocol
try:
from twisted.conch import manhole, telnet
from twisted.conch.insults import insults
TWISTED_CONCH_AVAILABLE = True
-except ImportError:
+except (ImportError, SyntaxError):
+ _TWISTED_CONCH_TRACEBACK = traceback.format_exc()
TWISTED_CONCH_AVAILABLE = False
from scrapy.exceptions import NotConfigured
@@ -20,6 +24,7 @@ from scrapy import signals
from scrapy.utils.trackref import print_live_refs
from scrapy.utils.engine import print_engine_status
from scrapy.utils.reactor import listen_tcp
+from scrapy.utils.decorators import defers
try:
import guppy
@@ -40,11 +45,20 @@ class TelnetConsole(protocol.ServerFactory):
if not crawler.settings.getbool('TELNETCONSOLE_ENABLED'):
raise NotConfigured
if not TWISTED_CONCH_AVAILABLE:
- raise NotConfigured
+ raise NotConfigured(
+ 'TELNETCONSOLE_ENABLED setting is True but required twisted '
+ 'modules failed to import:\n' + _TWISTED_CONCH_TRACEBACK)
self.crawler = crawler
self.noisy = False
self.portrange = [int(x) for x in crawler.settings.getlist('TELNETCONSOLE_PORT')]
self.host = crawler.settings['TELNETCONSOLE_HOST']
+ self.username = crawler.settings['TELNETCONSOLE_USERNAME']
+ self.password = crawler.settings['TELNETCONSOLE_PASSWORD']
+
+ if not self.password:
+ self.password = binascii.hexlify(os.urandom(8)).decode('utf8')
+ logger.info('Telnet Password: %s', self.password)
+
self.crawler.signals.connect(self.start_listening, signals.engine_started)
self.crawler.signals.connect(self.stop_listening, signals.engine_stopped)
@@ -63,9 +77,25 @@ class TelnetConsole(protocol.ServerFactory):
self.port.stopListening()
def protocol(self):
- telnet_vars = self._get_telnet_vars()
- return telnet.TelnetTransport(telnet.TelnetBootstrapProtocol,
- insults.ServerProtocol, manhole.Manhole, telnet_vars)
+ class Portal:
+ """An implementation of IPortal"""
+ @defers
+ def login(self_, credentials, mind, *interfaces):
+ if not (credentials.username == self.username.encode('utf8') and
+ credentials.checkPassword(self.password.encode('utf8'))):
+ raise ValueError("Invalid credentials")
+
+ protocol = telnet.TelnetBootstrapProtocol(
+ insults.ServerProtocol,
+ manhole.Manhole,
+ self._get_telnet_vars()
+ )
+ return (interfaces[0], protocol, lambda: None)
+
+ return telnet.TelnetTransport(
+ telnet.AuthenticatingTelnetProtocol,
+ Portal()
+ )
def _get_telnet_vars(self):
# Note: if you add entries here also update topics/telnetconsole.rst
@@ -81,8 +111,8 @@ class TelnetConsole(protocol.ServerFactory):
'p': pprint.pprint,
'prefs': print_live_refs,
'hpy': hpy,
- 'help': "This is Scrapy telnet console. For more info see: " \
- "https://doc.scrapy.org/en/latest/topics/telnetconsole.html",
+ 'help': "This is Scrapy telnet console. For more info see: "
+ "https://docs.scrapy.org/en/latest/topics/telnetconsole.html",
}
self.crawler.signals.send_catch_log(update_telnet_vars, telnet_vars=telnet_vars)
return telnet_vars
diff --git a/scrapy/http/__init__.py b/scrapy/http/__init__.py
index f04a9d3e5..e6c58e1f1 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/cookies.py b/scrapy/http/cookies.py
index a1e95102e..0903fd4f8 100644
--- a/scrapy/http/cookies.py
+++ b/scrapy/http/cookies.py
@@ -1,9 +1,8 @@
import time
-from six.moves.http_cookiejar import (
- CookieJar as _CookieJar, DefaultCookiePolicy, IPV4_RE
-)
+from http.cookiejar import CookieJar as _CookieJar, DefaultCookiePolicy, IPV4_RE
+
from scrapy.utils.httpobj import urlparse_cached
-from scrapy.utils.python import to_native_str
+from scrapy.utils.python import to_unicode
class CookieJar(object):
@@ -58,8 +57,8 @@ class CookieJar(object):
def clear_session_cookies(self, *args, **kwargs):
return self.jar.clear_session_cookies(*args, **kwargs)
- def clear(self):
- return self.jar.clear()
+ def clear(self, domain=None, path=None, name=None):
+ return self.jar.clear(domain, path, name)
def __iter__(self):
return iter(self.jar)
@@ -165,13 +164,13 @@ class WrappedRequest(object):
return name in self.request.headers
def get_header(self, name, default=None):
- return to_native_str(self.request.headers.get(name, default),
- errors='replace')
+ return to_unicode(self.request.headers.get(name, default),
+ errors='replace')
def header_items(self):
return [
- (to_native_str(k, errors='replace'),
- [to_native_str(x, errors='replace') for x in v])
+ (to_unicode(k, errors='replace'),
+ [to_unicode(x, errors='replace') for x in v])
for k, v in self.request.headers.items()
]
@@ -189,7 +188,7 @@ class WrappedResponse(object):
# python3 cookiejars calls get_all
def get_all(self, name, default=None):
- return [to_native_str(v, errors='replace')
+ return [to_unicode(v, errors='replace')
for v in self.response.headers.getlist(name)]
# python2 cookiejars calls getheaders
getheaders = get_all
diff --git a/scrapy/http/headers.py b/scrapy/http/headers.py
index 62507eb19..dcaaeddfa 100644
--- a/scrapy/http/headers.py
+++ b/scrapy/http/headers.py
@@ -1,4 +1,3 @@
-import six
from w3lib.http import headers_dict_to_raw
from scrapy.utils.datatypes import CaselessDict
from scrapy.utils.python import to_unicode
@@ -19,7 +18,7 @@ class Headers(CaselessDict):
"""Normalize values to bytes"""
if value is None:
value = []
- elif isinstance(value, (six.text_type, bytes)):
+ elif isinstance(value, (str, bytes)):
value = [value]
elif not hasattr(value, '__iter__'):
value = [value]
@@ -29,10 +28,10 @@ class Headers(CaselessDict):
def _tobytes(self, x):
if isinstance(x, bytes):
return x
- elif isinstance(x, six.text_type):
+ elif isinstance(x, str):
return x.encode(self.encoding)
elif isinstance(x, int):
- return six.text_type(x).encode(self.encoding)
+ return str(x).encode(self.encoding)
else:
raise TypeError('Unsupported value type: {}'.format(type(x)))
@@ -68,9 +67,6 @@ class Headers(CaselessDict):
self[key] = lst
def items(self):
- return list(self.iteritems())
-
- def iteritems(self):
return ((k, self.getlist(k)) for k in self.keys())
def values(self):
@@ -91,5 +87,3 @@ class Headers(CaselessDict):
def __copy__(self):
return self.__class__(self)
copy = __copy__
-
-
diff --git a/scrapy/http/request/__init__.py b/scrapy/http/request/__init__.py
index 13a92ffa0..6c536cb71 100644
--- a/scrapy/http/request/__init__.py
+++ b/scrapy/http/request/__init__.py
@@ -4,7 +4,6 @@ requests in Scrapy.
See documentation in docs/topics/request-response.rst
"""
-import six
from w3lib.url import safe_url_string
from scrapy.http.headers import Headers
@@ -12,13 +11,14 @@ from scrapy.utils.python import to_bytes
from scrapy.utils.trackref import object_ref
from scrapy.utils.url import escape_ajax
from scrapy.http.common import obsolete_setter
+from scrapy.utils.curl import curl_to_request_kwargs
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, cb_kwargs=None):
self._encoding = encoding # this one has to be set first
self.method = str(method).upper()
@@ -31,7 +31,6 @@ class Request(object_ref):
raise TypeError('callback must be a callable, got %s' % type(callback).__name__)
if errback is not None and not callable(errback):
raise TypeError('errback must be a callable, got %s' % type(errback).__name__)
- assert callback or not errback, "Cannot use errback without a callback"
self.callback = callback
self.errback = errback
@@ -40,8 +39,15 @@ class Request(object_ref):
self.dont_filter = dont_filter
self._meta = dict(meta) if meta else None
+ self._cb_kwargs = dict(cb_kwargs) if cb_kwargs else None
self.flags = [] if flags is None else list(flags)
+ @property
+ def cb_kwargs(self):
+ if self._cb_kwargs is None:
+ self._cb_kwargs = {}
+ return self._cb_kwargs
+
@property
def meta(self):
if self._meta is None:
@@ -52,13 +58,13 @@ class Request(object_ref):
return self._url
def _set_url(self, url):
- if not isinstance(url, six.string_types):
+ if not isinstance(url, str):
raise TypeError('Request url must be str or unicode, got %s:' % type(url).__name__)
s = safe_url_string(url, self.encoding)
self._url = escape_ajax(s)
- if ':' not in self._url:
+ if ('://' not in self._url) and (not self._url.startswith('data:')):
raise ValueError('Missing scheme in request url: %s' % self._url)
url = property(_get_url, obsolete_setter(_set_url, 'url'))
@@ -91,8 +97,39 @@ class Request(object_ref):
"""Create a new Request with the same attributes except for those
given new values.
"""
- for x in ['url', 'method', 'headers', 'body', 'cookies', 'meta',
- 'encoding', 'priority', 'dont_filter', 'callback', 'errback']:
+ for x in ['url', 'method', 'headers', 'body', 'cookies', 'meta', 'flags',
+ 'encoding', 'priority', 'dont_filter', 'callback', 'errback', 'cb_kwargs']:
kwargs.setdefault(x, getattr(self, x))
cls = kwargs.pop('cls', self.__class__)
return cls(*args, **kwargs)
+
+ @classmethod
+ def from_curl(cls, curl_command, ignore_unknown_options=True, **kwargs):
+ """Create a Request object from a string containing a `cURL
+ `_ command. It populates the HTTP method, the
+ URL, the headers, the cookies and the body. It accepts the same
+ arguments as the :class:`Request` class, taking preference and
+ overriding the values of the same arguments contained in the cURL
+ command.
+
+ Unrecognized options are ignored by default. To raise an error when
+ finding unknown options call this method by passing
+ ``ignore_unknown_options=False``.
+
+ .. caution:: Using :meth:`from_curl` from :class:`~scrapy.http.Request`
+ subclasses, such as :class:`~scrapy.http.JSONRequest`, or
+ :class:`~scrapy.http.XmlRpcRequest`, as well as having
+ :ref:`downloader middlewares `
+ and
+ :ref:`spider middlewares `
+ enabled, such as
+ :class:`~scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware`,
+ :class:`~scrapy.downloadermiddlewares.useragent.UserAgentMiddleware`,
+ or
+ :class:`~scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware`,
+ may modify the :class:`~scrapy.http.Request` object.
+
+ """
+ request_kwargs = curl_to_request_kwargs(curl_command, ignore_unknown_options)
+ request_kwargs.update(kwargs)
+ return cls(**request_kwargs)
diff --git a/scrapy/http/request/form.py b/scrapy/http/request/form.py
index 95b38e990..af02c8484 100644
--- a/scrapy/http/request/form.py
+++ b/scrapy/http/request/form.py
@@ -5,8 +5,7 @@ This module implements the FormRequest class which is a more convenient class
See documentation in docs/topics/request-response.rst
"""
-import six
-from six.moves.urllib.parse import urljoin, urlencode
+from urllib.parse import urljoin, urlencode
import lxml.html
from parsel.selector import create_root_node
@@ -18,6 +17,7 @@ from scrapy.utils.response import get_base_url
class FormRequest(Request):
+ valid_form_methods = ['GET', 'POST']
def __init__(self, *args, **kwargs):
formdata = kwargs.pop('formdata', None)
@@ -48,7 +48,13 @@ 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)
+ if method is not None:
+ method = method.upper()
+ if method not in cls.valid_form_methods:
+ method = 'GET'
+
return cls(url=url, method=method, formdata=formdata, **kwargs)
@@ -97,8 +103,7 @@ def _get_form(response, formname, formid, formnumber, formxpath):
el = el.getparent()
if el is None:
break
- encoded = formxpath if six.PY3 else formxpath.encode('unicode_escape')
- raise ValueError('No """)
- req = self.request_class.from_response(response, \
- clickdata={u'name': u'price in \u00a3'})
+ req = self.request_class.from_response(
+ response, clickdata={u'name': u'price in \u00a3'}
+ )
fs = _qs(req, to_unicode=True)
self.assertTrue(fs[u'price in \u00a3'])
@@ -592,8 +719,9 @@ class FormRequestTest(RequestTest):
""",
encoding='latin1')
- req = self.request_class.from_response(response, \
- clickdata={u'name': u'price in \u00a5'})
+ req = self.request_class.from_response(
+ response, clickdata={u'name': u'price in \u00a5'}
+ )
fs = _qs(req, to_unicode=True, encoding='latin1')
self.assertTrue(fs[u'price in \u00a5'])
@@ -608,8 +736,9 @@ class FormRequestTest(RequestTest):
""")
- req = self.request_class.from_response(response, formname='form2', \
- clickdata={u'name': u'clickable'})
+ req = self.request_class.from_response(
+ response, formname='form2', clickdata={u'name': u'clickable'}
+ )
fs = _qs(req)
self.assertEqual(fs[b'clickable'], [b'clicked2'])
self.assertEqual(fs[b'field2'], [b'value2'])
@@ -617,8 +746,9 @@ class FormRequestTest(RequestTest):
def test_from_response_override_clickable(self):
response = _buildresponse(''' ''')
- req = self.request_class.from_response(response, \
- formdata={'clickme': 'two'}, clickdata={'name': 'clickme'})
+ req = self.request_class.from_response(
+ response, formdata={'clickme': 'two'}, clickdata={'name': 'clickme'}
+ )
fs = _qs(req)
self.assertEqual(fs[b'clickme'], [b'two'])
@@ -703,7 +833,7 @@ class FormRequestTest(RequestTest):
""")
- r1 = self.request_class.from_response(response, formdata={'two':'3'})
+ r1 = self.request_class.from_response(response, formdata={'two': '3'})
self.assertEqual(r1.method, 'POST')
self.assertEqual(r1.headers['Content-type'], b'application/x-www-form-urlencoded')
fs = _qs(r1)
@@ -745,7 +875,7 @@ class FormRequestTest(RequestTest):
""")
- self.assertRaises(IndexError, self.request_class.from_response, \
+ self.assertRaises(IndexError, self.request_class.from_response,
response, formname="form3", formnumber=2)
def test_from_response_formid_exists(self):
@@ -799,7 +929,7 @@ class FormRequestTest(RequestTest):
""")
- self.assertRaises(IndexError, self.request_class.from_response, \
+ self.assertRaises(IndexError, self.request_class.from_response,
response, formid="form3", formnumber=2)
def test_from_response_select(self):
@@ -951,10 +1081,9 @@ class FormRequestTest(RequestTest):
self.assertEqual(fs, {})
xpath = u"//form[@name='\u03b1']"
- encoded = xpath if six.PY3 else xpath.encode('unicode_escape')
- self.assertRaisesRegexp(ValueError, re.escape(encoded),
- self.request_class.from_response,
- response, formxpath=xpath)
+ self.assertRaisesRegex(ValueError, re.escape(xpath),
+ self.request_class.from_response,
+ response, formxpath=xpath)
def test_from_response_button_submit(self):
response = _buildresponse(
@@ -1068,6 +1197,20 @@ 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)
+ r = self.request_class.from_response(response)
+ self.assertEqual(r.method, method)
+
+ response = _buildresponse(body % 'UNKNOWN')
+ r = self.request_class.from_response(response)
+ self.assertEqual(r.method, 'GET')
+
def _buildresponse(body, **kwargs):
kwargs.setdefault('body', body)
@@ -1081,10 +1224,7 @@ def _qs(req, encoding='utf-8', to_unicode=False):
qs = req.body
else:
qs = req.url.partition('?')[2]
- if six.PY2:
- uqs = unquote(to_native_str(qs, encoding))
- elif six.PY3:
- uqs = unquote_to_bytes(qs)
+ uqs = unquote_to_bytes(qs)
if to_unicode:
uqs = uqs.decode(encoding)
return parse_qs(uqs, True)
@@ -1100,7 +1240,7 @@ class XmlRpcRequestTest(RequestTest):
r = self.request_class('http://scrapytest.org/rpc2', **kwargs)
self.assertEqual(r.headers[b'Content-Type'], b'text/xml')
self.assertEqual(r.body,
- to_bytes(xmlrpclib.dumps(**kwargs),
+ to_bytes(xmlrpc.client.dumps(**kwargs),
encoding=kwargs.get('encoding', 'utf-8')))
self.assertEqual(r.method, 'POST')
self.assertEqual(r.encoding, kwargs.get('encoding', 'utf-8'))
@@ -1119,5 +1259,169 @@ class XmlRpcRequestTest(RequestTest):
self._test_request(params=(u'pas£',), encoding='latin1')
+class JsonRequestTest(RequestTest):
+ request_class = JsonRequest
+ 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'')
+
+ body = b'body'
+ r2 = self.request_class(url="http://www.example.com/", body=body)
+ self.assertEqual(r2.body, body)
+
+ data = {
+ 'name': 'value',
+ }
+ r3 = self.request_class(url="http://www.example.com/", data=data)
+ self.assertEqual(r3.body, to_bytes(json.dumps(data)))
+
+ # empty data
+ r4 = self.request_class(url="http://www.example.com/", data=[])
+ self.assertEqual(r4.body, to_bytes(json.dumps([])))
+
+ 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)
+ self.assertEqual(r5.method, 'GET')
+ 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'')
+ self.assertEqual(r6.method, 'GET')
+ 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 to 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()
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_http_response.py b/tests/test_http_response.py
index 820758dc9..4c1b2afc3 100644
--- a/tests/test_http_response.py
+++ b/tests/test_http_response.py
@@ -1,13 +1,12 @@
# -*- coding: utf-8 -*-
import unittest
-import six
from w3lib.encoding import resolve_encoding
from scrapy.http import (Request, Response, TextResponse, HtmlResponse,
XmlResponse, Headers)
from scrapy.selector import Selector
-from scrapy.utils.python import to_native_str
+from scrapy.utils.python import to_unicode
from scrapy.exceptions import NotSupported
from scrapy.link import Link
from tests import get_testdata
@@ -21,8 +20,7 @@ class BaseResponseTest(unittest.TestCase):
# Response requires url in the consturctor
self.assertRaises(Exception, self.response_class)
self.assertTrue(isinstance(self.response_class('http://example.com/'), self.response_class))
- if not six.PY2:
- self.assertRaises(TypeError, self.response_class, b"http://example.com")
+ self.assertRaises(TypeError, self.response_class, b"http://example.com")
# body can be str or None
self.assertTrue(isinstance(self.response_class('http://example.com/', body=b''), self.response_class))
self.assertTrue(isinstance(self.response_class('http://example.com/', body=b'body'), self.response_class))
@@ -103,7 +101,7 @@ class BaseResponseTest(unittest.TestCase):
self.assertEqual(r4.flags, [])
def _assert_response_values(self, response, encoding, body):
- if isinstance(body, six.text_type):
+ if isinstance(body, str):
body_unicode = body
body_bytes = body.encode(encoding)
else:
@@ -111,7 +109,7 @@ class BaseResponseTest(unittest.TestCase):
body_bytes = body
assert isinstance(response.body, bytes)
- assert isinstance(response.text, six.text_type)
+ assert isinstance(response.text, str)
self._assert_response_encoding(response, encoding)
self.assertEqual(response.body, body_bytes)
self.assertEqual(response.body_as_unicode(), body_unicode)
@@ -135,14 +133,16 @@ class BaseResponseTest(unittest.TestCase):
r = self.response_class("http://example.com", body=b'hello')
if self.response_class == Response:
msg = "Response content isn't text"
- self.assertRaisesRegexp(AttributeError, msg, getattr, r, 'text')
- self.assertRaisesRegexp(NotSupported, msg, r.css, 'body')
- self.assertRaisesRegexp(NotSupported, msg, r.xpath, '//body')
+ self.assertRaisesRegex(AttributeError, msg, getattr, r, 'text')
+ self.assertRaisesRegex(NotSupported, msg, r.css, 'body')
+ self.assertRaisesRegex(NotSupported, msg, r.xpath, '//body')
else:
r.text
r.css('body')
r.xpath('//body')
+ # Response.follow
+
def test_follow_url_absolute(self):
self._assert_followed_url('http://foo.example.com',
'http://foo.example.com')
@@ -166,6 +166,72 @@ class BaseResponseTest(unittest.TestCase):
def test_follow_whitespace_link(self):
self._assert_followed_url(Link('http://example.com/foo '),
'http://example.com/foo%20')
+
+ # Response.follow_all
+
+ def test_follow_all_absolute(self):
+ url_list = ['http://example.org', 'http://www.example.org',
+ 'http://example.com', 'http://www.example.com']
+ self._assert_followed_all_urls(url_list, url_list)
+
+ def test_follow_all_relative(self):
+ relative = ['foo', 'bar', 'foo/bar', 'bar/foo']
+ absolute = [
+ 'http://example.com/foo',
+ 'http://example.com/bar',
+ 'http://example.com/foo/bar',
+ 'http://example.com/bar/foo',
+ ]
+ self._assert_followed_all_urls(relative, absolute)
+
+ def test_follow_all_links(self):
+ absolute = [
+ 'http://example.com/foo',
+ 'http://example.com/bar',
+ 'http://example.com/foo/bar',
+ 'http://example.com/bar/foo',
+ ]
+ links = map(Link, absolute)
+ self._assert_followed_all_urls(links, absolute)
+
+ def test_follow_all_invalid(self):
+ r = self.response_class("http://example.com")
+ if self.response_class == Response:
+ with self.assertRaises(TypeError):
+ list(r.follow_all(urls=None))
+ with self.assertRaises(TypeError):
+ list(r.follow_all(urls=12345))
+ with self.assertRaises(ValueError):
+ list(r.follow_all(urls=[None]))
+ else:
+ with self.assertRaises(ValueError):
+ list(r.follow_all(urls=None))
+ with self.assertRaises(TypeError):
+ list(r.follow_all(urls=12345))
+ with self.assertRaises(ValueError):
+ list(r.follow_all(urls=[None]))
+
+ def test_follow_all_whitespace(self):
+ relative = ['foo ', 'bar ', 'foo/bar ', 'bar/foo ']
+ absolute = [
+ 'http://example.com/foo%20',
+ 'http://example.com/bar%20',
+ 'http://example.com/foo/bar%20',
+ 'http://example.com/bar/foo%20',
+ ]
+ self._assert_followed_all_urls(relative, absolute)
+
+ def test_follow_all_whitespace_links(self):
+ absolute = [
+ 'http://example.com/foo ',
+ 'http://example.com/bar ',
+ 'http://example.com/foo/bar ',
+ 'http://example.com/bar/foo ',
+ ]
+ links = map(Link, absolute)
+ expected = [u.replace(' ', '%20') for u in absolute]
+ self._assert_followed_all_urls(links, expected)
+
def _assert_followed_url(self, follow_obj, target_url, response=None):
if response is None:
response = self._links_response()
@@ -173,8 +239,21 @@ class BaseResponseTest(unittest.TestCase):
self.assertEqual(req.url, target_url)
return req
+ def _assert_followed_all_urls(self, follow_obj, target_urls, response=None):
+ if response is None:
+ response = self._links_response()
+ followed = response.follow_all(follow_obj)
+ for req, target in zip(followed, target_urls):
+ self.assertEqual(req.url, target)
+ yield req
+
def _links_response(self):
- body = get_testdata('link_extractor', 'sgml_linkextractor.html')
+ body = get_testdata('link_extractor', 'linkextractor.html')
+ resp = self.response_class('http://example.com/index', body=body)
+ return resp
+
+ def _links_response_no_href(self):
+ body = get_testdata('link_extractor', 'linkextractor_no_href.html')
resp = self.response_class('http://example.com/index', body=body)
return resp
@@ -205,11 +284,11 @@ class TextResponseTest(BaseResponseTest):
assert isinstance(resp.url, str)
resp = self.response_class(url=u"http://www.example.com/price/\xa3", encoding='utf-8')
- self.assertEqual(resp.url, to_native_str(b'http://www.example.com/price/\xc2\xa3'))
+ self.assertEqual(resp.url, to_unicode(b'http://www.example.com/price/\xc2\xa3'))
resp = self.response_class(url=u"http://www.example.com/price/\xa3", encoding='latin-1')
self.assertEqual(resp.url, 'http://www.example.com/price/\xa3')
resp = self.response_class(u"http://www.example.com/price/\xa3", headers={"Content-type": ["text/html; charset=utf-8"]})
- self.assertEqual(resp.url, to_native_str(b'http://www.example.com/price/\xc2\xa3'))
+ self.assertEqual(resp.url, to_unicode(b'http://www.example.com/price/\xc2\xa3'))
resp = self.response_class(u"http://www.example.com/price/\xa3", headers={"Content-type": ["text/html; charset=iso-8859-1"]})
self.assertEqual(resp.url, 'http://www.example.com/price/\xa3')
@@ -221,11 +300,11 @@ class TextResponseTest(BaseResponseTest):
r1 = self.response_class('http://www.example.com', body=original_string, encoding='cp1251')
# check body_as_unicode
- self.assertTrue(isinstance(r1.body_as_unicode(), six.text_type))
+ self.assertTrue(isinstance(r1.body_as_unicode(), str))
self.assertEqual(r1.body_as_unicode(), unicode_string)
# check response.text
- self.assertTrue(isinstance(r1.text, six.text_type))
+ self.assertTrue(isinstance(r1.text, str))
self.assertEqual(r1.text, unicode_string)
def test_encoding(self):
@@ -318,8 +397,8 @@ class TextResponseTest(BaseResponseTest):
assert u'SUFFIX' in r.text, repr(r.text)
# Do not destroy html tags due to encoding bugs
- r = self.response_class("http://example.com", encoding='utf-8', \
- body=b'\xf0value ')
+ r = self.response_class("http://example.com", encoding='utf-8',
+ body=b'\xf0value ')
assert u'value ' in r.text, repr(r.text)
# FIXME: This test should pass once we stop using BeautifulSoup's UnicodeDammit in TextResponse
@@ -336,11 +415,11 @@ class TextResponseTest(BaseResponseTest):
self.assertIs(response.selector.response, response)
self.assertEqual(
- response.selector.xpath("//title/text()").extract(),
+ response.selector.xpath("//title/text()").getall(),
[u'Some page']
)
self.assertEqual(
- response.selector.css("title::text").extract(),
+ response.selector.css("title::text").getall(),
[u'Some page']
)
self.assertEqual(
@@ -353,12 +432,12 @@ class TextResponseTest(BaseResponseTest):
response = self.response_class("http://www.example.com", body=body)
self.assertEqual(
- response.xpath("//title/text()").extract(),
- response.selector.xpath("//title/text()").extract(),
+ response.xpath("//title/text()").getall(),
+ response.selector.xpath("//title/text()").getall(),
)
self.assertEqual(
- response.css("title::text").extract(),
- response.selector.css("title::text").extract(),
+ response.css("title::text").getall(),
+ response.selector.css("title::text").getall(),
)
def test_selector_shortcuts_kwargs(self):
@@ -366,13 +445,13 @@ class TextResponseTest(BaseResponseTest):
response = self.response_class("http://www.example.com", body=body)
self.assertEqual(
- response.xpath("normalize-space(//p[@class=$pclass])", pclass="content").extract(),
- response.xpath("normalize-space(//p[@class=\"content\"])").extract(),
+ response.xpath("normalize-space(//p[@class=$pclass])", pclass="content").getall(),
+ response.xpath("normalize-space(//p[@class=\"content\"])").getall(),
)
self.assertEqual(
response.xpath("//title[count(following::p[@class=$pclass])=$pcount]/text()",
- pclass="content", pcount=1).extract(),
- response.xpath("//title[count(following::p[@class=\"content\"])=1]/text()").extract(),
+ pclass="content", pcount=1).getall(),
+ response.xpath("//title[count(following::p[@class=\"content\"])=1]/text()").getall(),
)
def test_urljoin_with_base_url(self):
@@ -425,13 +504,13 @@ class TextResponseTest(BaseResponseTest):
def test_follow_selector_list(self):
resp = self._links_response()
- self.assertRaisesRegexp(ValueError, 'SelectorList',
- resp.follow, resp.css('a'))
+ self.assertRaisesRegex(ValueError, 'SelectorList',
+ resp.follow, resp.css('a'))
def test_follow_selector_invalid(self):
resp = self._links_response()
- self.assertRaisesRegexp(ValueError, 'Unsupported',
- resp.follow, resp.xpath('count(//div)')[0])
+ self.assertRaisesRegex(ValueError, 'Unsupported',
+ resp.follow, resp.xpath('count(//div)')[0])
def test_follow_selector_attribute(self):
resp = self._links_response()
@@ -443,8 +522,8 @@ class TextResponseTest(BaseResponseTest):
url='http://example.com',
body=b'click me ',
)
- self.assertRaisesRegexp(ValueError, 'no href',
- resp.follow, resp.css('a')[0])
+ self.assertRaisesRegex(ValueError, 'no href',
+ resp.follow, resp.css('a')[0])
def test_follow_whitespace_selector(self):
resp = self.response_class(
@@ -483,6 +562,53 @@ class TextResponseTest(BaseResponseTest):
)
self.assertEqual(req.encoding, 'cp1251')
+ def test_follow_all_css(self):
+ expected = [
+ 'http://example.com/sample3.html',
+ 'http://example.com/innertag.html',
+ ]
+ response = self._links_response()
+ extracted = [r.url for r in response.follow_all(css='a[href*="example.com"]')]
+ self.assertEqual(expected, extracted)
+
+ def test_follow_all_css_skip_invalid(self):
+ expected = [
+ 'http://example.com/page/1/',
+ 'http://example.com/page/3/',
+ 'http://example.com/page/4/',
+ ]
+ response = self._links_response_no_href()
+ extracted1 = [r.url for r in response.follow_all(css='.pagination a')]
+ self.assertEqual(expected, extracted1)
+ extracted2 = [r.url for r in response.follow_all(response.css('.pagination a'))]
+ self.assertEqual(expected, extracted2)
+
+ def test_follow_all_xpath(self):
+ expected = [
+ 'http://example.com/sample3.html',
+ 'http://example.com/innertag.html',
+ ]
+ response = self._links_response()
+ extracted = response.follow_all(xpath='//a[contains(@href, "example.com")]')
+ self.assertEqual(expected, [r.url for r in extracted])
+
+ def test_follow_all_xpath_skip_invalid(self):
+ expected = [
+ 'http://example.com/page/1/',
+ 'http://example.com/page/3/',
+ 'http://example.com/page/4/',
+ ]
+ response = self._links_response_no_href()
+ extracted1 = [r.url for r in response.follow_all(xpath='//div[@id="pagination"]/a')]
+ self.assertEqual(expected, extracted1)
+ extracted2 = [r.url for r in response.follow_all(response.xpath('//div[@id="pagination"]/a'))]
+ self.assertEqual(expected, extracted2)
+
+ def test_follow_all_too_many_arguments(self):
+ response = self._links_response()
+ with self.assertRaises(ValueError):
+ response.follow_all(css='a[href*="example.com"]', xpath='//a[contains(@href, "example.com")]')
+
class HtmlResponseTest(TextResponseTest):
@@ -534,7 +660,7 @@ class XmlResponseTest(TextResponseTest):
r2 = self.response_class("http://www.example.com", body=body)
self._assert_response_values(r2, 'iso-8859-1', body)
- # make sure replace() preserves the explicit encoding passed in the constructor
+ # make sure replace() preserves the explicit encoding passed in the __init__ method
body = b""" """
r3 = self.response_class("http://www.example.com", body=body, encoding='utf-8')
body2 = b"New body"
@@ -562,7 +688,7 @@ class XmlResponseTest(TextResponseTest):
self.assertIs(response.selector.response, response)
self.assertEqual(
- response.selector.xpath("//elem/text()").extract(),
+ response.selector.xpath("//elem/text()").getall(),
[u'value']
)
@@ -571,8 +697,8 @@ class XmlResponseTest(TextResponseTest):
response = self.response_class("http://www.example.com", body=body)
self.assertEqual(
- response.xpath("//elem/text()").extract(),
- response.selector.xpath("//elem/text()").extract(),
+ response.xpath("//elem/text()").getall(),
+ response.selector.xpath("//elem/text()").getall(),
)
def test_selector_shortcuts_kwargs(self):
@@ -583,12 +709,12 @@ class XmlResponseTest(TextResponseTest):
response = self.response_class("http://www.example.com", body=body)
self.assertEqual(
- response.xpath("//s:elem/text()", namespaces={'s': 'http://scrapy.org'}).extract(),
- response.selector.xpath("//s:elem/text()", namespaces={'s': 'http://scrapy.org'}).extract(),
+ response.xpath("//s:elem/text()", namespaces={'s': 'http://scrapy.org'}).getall(),
+ response.selector.xpath("//s:elem/text()", namespaces={'s': 'http://scrapy.org'}).getall(),
)
response.selector.register_namespace('s2', 'http://scrapy.org')
self.assertEqual(
- response.xpath("//s1:elem/text()", namespaces={'s1': 'http://scrapy.org'}).extract(),
- response.selector.xpath("//s2:elem/text()").extract(),
+ response.xpath("//s1:elem/text()", namespaces={'s1': 'http://scrapy.org'}).getall(),
+ response.selector.xpath("//s2:elem/text()").getall(),
)
diff --git a/tests/test_item.py b/tests/test_item.py
index 2c1eb0dd3..30463a0f5 100644
--- a/tests/test_item.py
+++ b/tests/test_item.py
@@ -1,10 +1,10 @@
import sys
import unittest
+from unittest import mock
+from warnings import catch_warnings
-import six
-
-from scrapy.item import ABCMeta, Item, ItemMeta, Field
-from tests import mock
+from scrapy.exceptions import ScrapyDeprecationWarning
+from scrapy.item import ABCMeta, DictItem, Field, Item, ItemMeta
PY36_PLUS = (sys.version_info.major >= 3) and (sys.version_info.minor >= 6)
@@ -60,12 +60,8 @@ class ItemTest(unittest.TestCase):
i['number'] = 123
itemrepr = repr(i)
- if six.PY2:
- self.assertEqual(itemrepr,
- "{'name': u'John Doe', 'number': 123}")
- else:
- self.assertEqual(itemrepr,
- "{'name': 'John Doe', 'number': 123}")
+ self.assertEqual(itemrepr,
+ "{'name': 'John Doe', 'number': 123}")
i2 = eval(itemrepr)
self.assertEqual(i2['name'], 'John Doe')
@@ -243,12 +239,31 @@ class ItemTest(unittest.TestCase):
def test_copy(self):
class TestItem(Item):
name = Field()
- item = TestItem({'name':'lower'})
+ item = TestItem({'name': 'lower'})
copied_item = item.copy()
self.assertNotEqual(id(item), id(copied_item))
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']
+
+ def test_dictitem_deprecation_warning(self):
+ """Make sure the DictItem deprecation warning is not issued for
+ Item"""
+ with catch_warnings(record=True) as warnings:
+ item = Item()
+ self.assertEqual(len(warnings), 0)
+ class SubclassedItem(Item):
+ pass
+ subclassed_item = SubclassedItem()
+ self.assertEqual(len(warnings), 0)
+
class ItemMetaTest(unittest.TestCase):
@@ -285,7 +300,7 @@ class ItemMetaTest(unittest.TestCase):
class ItemMetaClassCellRegression(unittest.TestCase):
def test_item_meta_classcell_regression(self):
- class MyItem(six.with_metaclass(ItemMeta, Item)):
+ class MyItem(Item, metaclass=ItemMeta):
def __init__(self, *args, **kwargs):
# This call to super() trigger the __classcell__ propagation
# requirement. When not done properly raises an error:
@@ -294,5 +309,20 @@ class ItemMetaClassCellRegression(unittest.TestCase):
super(MyItem, self).__init__(*args, **kwargs)
+class DictItemTest(unittest.TestCase):
+
+ def test_deprecation_warning(self):
+ with catch_warnings(record=True) as warnings:
+ dict_item = DictItem()
+ self.assertEqual(len(warnings), 1)
+ self.assertEqual(warnings[0].category, ScrapyDeprecationWarning)
+ with catch_warnings(record=True) as warnings:
+ class SubclassedDictItem(DictItem):
+ pass
+ subclassed_dict_item = SubclassedDictItem()
+ self.assertEqual(len(warnings), 1)
+ self.assertEqual(warnings[0].category, ScrapyDeprecationWarning)
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_link.py b/tests/test_link.py
index 955430b37..e0f1efffa 100644
--- a/tests/test_link.py
+++ b/tests/test_link.py
@@ -1,6 +1,4 @@
import unittest
-import warnings
-import six
from scrapy.link import Link
@@ -45,13 +43,6 @@ class LinkTest(unittest.TestCase):
l2 = eval(repr(l1))
self._assert_same_links(l1, l2)
- def test_non_str_url_py2(self):
- if six.PY2:
- with warnings.catch_warnings(record=True) as w:
- link = Link(u"http://www.example.com/\xa3")
- self.assertIsInstance(link.url, str)
- self.assertEqual(link.url, b'http://www.example.com/\xc2\xa3')
- assert len(w) == 1, "warning not issued"
- else:
- with self.assertRaises(TypeError):
- Link(b"http://www.example.com/\xc2\xa3")
+ def test_bytes_url(self):
+ with self.assertRaises(TypeError):
+ Link(b"http://www.example.com/\xc2\xa3")
diff --git a/tests/test_linkextractors.py b/tests/test_linkextractors.py
index 1d7c4f311..38fb8fb4a 100644
--- a/tests/test_linkextractors.py
+++ b/tests/test_linkextractors.py
@@ -1,10 +1,13 @@
import re
import unittest
+from warnings import catch_warnings
import pytest
+from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.http import HtmlResponse, XmlResponse
from scrapy.link import Link
+from scrapy.linkextractors import FilteringLinkExtractor
from scrapy.linkextractors.lxmlhtml import LxmlLinkExtractor
from tests import get_testdata
@@ -16,7 +19,7 @@ class Base:
escapes_whitespace = False
def setUp(self):
- body = get_testdata('link_extractor', 'sgml_linkextractor.html')
+ body = get_testdata('link_extractor', 'linkextractor.html')
self.response = HtmlResponse(url='http://example.com/index', body=body)
def test_urls_type(self):
@@ -288,7 +291,7 @@ class Base:
response = HtmlResponse("http://example.org/somepage/index.html", body=html, encoding='windows-1252')
def process_value(value):
- m = re.search("javascript:goToPage\('(.*?)'", value)
+ m = re.search(r"javascript:goToPage\('(.*?)'", value)
if m:
return m.group(1)
@@ -322,7 +325,7 @@ class Base:
Link(url=page4_url, text=u'href with whitespaces'),
])
- lx = self.extractor_cls(attrs=("href","src"), tags=("a","area","img"), deny_extensions=())
+ lx = self.extractor_cls(attrs=("href", "src"), tags=("a", "area", "img"), deny_extensions=())
self.assertEqual(lx.extract_links(self.response), [
Link(url='http://example.com/sample1.html', text=u''),
Link(url='http://example.com/sample2.html', text=u'sample 2'),
@@ -360,7 +363,7 @@ class Base:
Link(url='http://example.com/sample2.html', text=u'sample 2'),
])
- lx = self.extractor_cls(tags=("a","img"), attrs=("href", "src"), deny_extensions=())
+ lx = self.extractor_cls(tags=("a", "img"), attrs=("href", "src"), deny_extensions=())
self.assertEqual(lx.extract_links(response), [
Link(url='http://example.com/sample2.html', text=u'sample 2'),
Link(url='http://example.com/sample2.jpg', text=u''),
@@ -451,6 +454,17 @@ class Base:
Link(url='http://example.org/item3.html', text=u'Item 3', nofollow=False),
])
+ def test_ftp_links(self):
+ body = b"""
+
+
+ """
+ response = HtmlResponse("http://www.example.com/index.html", body=body, encoding='utf8')
+ lx = self.extractor_cls()
+ self.assertEqual(lx.extract_links(response), [
+ Link(url='ftp://www.external.com/', text=u'An Item', fragment='', nofollow=False),
+ ])
+
class LxmlLinkExtractorTestCase(Base.LinkExtractorTestCase):
extractor_cls = LxmlLinkExtractor
@@ -468,7 +482,59 @@ 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()
+ def test_filteringlinkextractor_deprecation_warning(self):
+ """Make sure the FilteringLinkExtractor deprecation warning is not
+ issued for LxmlLinkExtractor"""
+ with catch_warnings(record=True) as warnings:
+ LxmlLinkExtractor()
+ self.assertEqual(len(warnings), 0)
+
+ class SubclassedLxmlLinkExtractor(LxmlLinkExtractor):
+ pass
+
+ SubclassedLxmlLinkExtractor()
+ self.assertEqual(len(warnings), 0)
+
+
+class FilteringLinkExtractorTest(unittest.TestCase):
+
+ def test_deprecation_warning(self):
+ args = [None] * 10
+ with catch_warnings(record=True) as warnings:
+ FilteringLinkExtractor(*args)
+ self.assertEqual(len(warnings), 1)
+ self.assertEqual(warnings[0].category, ScrapyDeprecationWarning)
+ with catch_warnings(record=True) as warnings:
+ class SubclassedFilteringLinkExtractor(FilteringLinkExtractor):
+ pass
+ SubclassedFilteringLinkExtractor(*args)
+ self.assertEqual(len(warnings), 1)
+ self.assertEqual(warnings[0].category, ScrapyDeprecationWarning)
diff --git a/tests/test_linkextractors_deprecated.py b/tests/test_linkextractors_deprecated.py
deleted file mode 100644
index 1366971be..000000000
--- a/tests/test_linkextractors_deprecated.py
+++ /dev/null
@@ -1,233 +0,0 @@
-# -*- coding: utf-8 -*-
-import unittest
-from scrapy.linkextractors.regex import RegexLinkExtractor
-from scrapy.http import HtmlResponse
-from scrapy.link import Link
-from scrapy.linkextractors.htmlparser import HtmlParserLinkExtractor
-from scrapy.linkextractors.sgml import SgmlLinkExtractor, BaseSgmlLinkExtractor
-from tests import get_testdata
-
-from tests.test_linkextractors import Base
-
-
-class BaseSgmlLinkExtractorTestCase(unittest.TestCase):
- # XXX: should we move some of these tests to base link extractor tests?
-
- def test_basic(self):
- html = """Page title
- Item 12
- About us
-
- Other category
- >>
-
- """
- response = HtmlResponse("http://example.org/somepage/index.html", body=html)
-
- lx = BaseSgmlLinkExtractor() # default: tag=a, attr=href
- self.assertEqual(lx.extract_links(response),
- [Link(url='http://example.org/somepage/item/12.html', text='Item 12'),
- Link(url='http://example.org/about.html', text='About us'),
- Link(url='http://example.org/othercat.html', text='Other category'),
- Link(url='http://example.org/', text='>>'),
- Link(url='http://example.org/', text='')])
-
- def test_base_url(self):
- html = """Page title
- Item 12
- """
- response = HtmlResponse("http://example.org/somepage/index.html", body=html)
-
- lx = BaseSgmlLinkExtractor() # default: tag=a, attr=href
- self.assertEqual(lx.extract_links(response),
- [Link(url='http://otherdomain.com/base/item/12.html', text='Item 12')])
-
- # base url is an absolute path and relative to host
- html = """Page title
- Item 12
"""
- response = HtmlResponse("https://example.org/somepage/index.html", body=html)
- self.assertEqual(lx.extract_links(response),
- [Link(url='https://example.org/item/12.html', text='Item 12')])
-
- # base url has no scheme
- html = """Page title
- Item 12
"""
- response = HtmlResponse("https://example.org/somepage/index.html", body=html)
- self.assertEqual(lx.extract_links(response),
- [Link(url='https://noschemedomain.com/path/to/item/12.html', text='Item 12')])
-
- def test_link_text_wrong_encoding(self):
- html = """Wrong: \xed
"""
- response = HtmlResponse("http://www.example.com", body=html, encoding='utf-8')
- lx = BaseSgmlLinkExtractor()
- self.assertEqual(lx.extract_links(response), [
- Link(url='http://www.example.com/item/12.html', text=u'Wrong: \ufffd'),
- ])
-
- def test_extraction_encoding(self):
- body = get_testdata('link_extractor', 'linkextractor_noenc.html')
- response_utf8 = HtmlResponse(url='http://example.com/utf8', body=body, headers={'Content-Type': ['text/html; charset=utf-8']})
- response_noenc = HtmlResponse(url='http://example.com/noenc', body=body)
- body = get_testdata('link_extractor', 'linkextractor_latin1.html')
- response_latin1 = HtmlResponse(url='http://example.com/latin1', body=body)
-
- lx = BaseSgmlLinkExtractor()
- self.assertEqual(lx.extract_links(response_utf8), [
- Link(url='http://example.com/sample_%C3%B1.html', text=''),
- Link(url='http://example.com/sample_%E2%82%AC.html', text='sample \xe2\x82\xac text'.decode('utf-8')),
- ])
-
- self.assertEqual(lx.extract_links(response_noenc), [
- Link(url='http://example.com/sample_%C3%B1.html', text=''),
- Link(url='http://example.com/sample_%E2%82%AC.html', text='sample \xe2\x82\xac text'.decode('utf-8')),
- ])
-
- # document encoding does not affect URL path component, only query part
- # >>> u'sample_ñ.html'.encode('utf8')
- # b'sample_\xc3\xb1.html'
- # >>> u"sample_á.html".encode('utf8')
- # b'sample_\xc3\xa1.html'
- # >>> u"sample_ö.html".encode('utf8')
- # b'sample_\xc3\xb6.html'
- # >>> u"£32".encode('latin1')
- # b'\xa332'
- # >>> u"µ".encode('latin1')
- # b'\xb5'
- self.assertEqual(lx.extract_links(response_latin1), [
- Link(url='http://example.com/sample_%C3%B1.html', text=''),
- Link(url='http://example.com/sample_%C3%A1.html', text='sample \xe1 text'.decode('latin1')),
- Link(url='http://example.com/sample_%C3%B6.html?price=%A332&%B5=unit', text=''),
- ])
-
- def test_matches(self):
- url1 = 'http://lotsofstuff.com/stuff1/index'
- url2 = 'http://evenmorestuff.com/uglystuff/index'
-
- lx = BaseSgmlLinkExtractor()
- self.assertEqual(lx.matches(url1), True)
- self.assertEqual(lx.matches(url2), True)
-
-
-class HtmlParserLinkExtractorTestCase(unittest.TestCase):
-
- def setUp(self):
- body = get_testdata('link_extractor', 'sgml_linkextractor.html')
- self.response = HtmlResponse(url='http://example.com/index', body=body)
-
- def test_extraction(self):
- # Default arguments
- lx = HtmlParserLinkExtractor()
- self.assertEqual(lx.extract_links(self.response), [
- Link(url='http://example.com/sample2.html', text=u'sample 2'),
- Link(url='http://example.com/sample3.html', text=u'sample 3 text'),
- Link(url='http://example.com/sample3.html', text=u'sample 3 repetition'),
- Link(url='http://example.com/sample3.html#foo', text=u'sample 3 repetition with fragment'),
- Link(url='http://www.google.com/something', text=u''),
- Link(url='http://example.com/innertag.html', text=u'inner tag'),
- Link(url='http://example.com/page%204.html', text=u'href with whitespaces'),
- ])
-
- def test_link_wrong_href(self):
- html = """
- Item 1
- Item 2
- Item 3
- """
- response = HtmlResponse("http://example.org/index.html", body=html)
- lx = HtmlParserLinkExtractor()
- self.assertEqual([link for link in lx.extract_links(response)], [
- Link(url='http://example.org/item1.html', text=u'Item 1', nofollow=False),
- Link(url='http://example.org/item3.html', text=u'Item 3', nofollow=False),
- ])
-
-
-class SgmlLinkExtractorTestCase(Base.LinkExtractorTestCase):
- extractor_cls = SgmlLinkExtractor
- escapes_whitespace = True
-
- def test_deny_extensions(self):
- html = """asd and """
- response = HtmlResponse("http://example.org/", body=html)
- lx = SgmlLinkExtractor(deny_extensions="jpg")
- self.assertEqual(lx.extract_links(response), [
- Link(url='http://example.org/page.html', text=u'asd'),
- ])
-
- def test_attrs_sgml(self):
- html = """
- sample text 2 """
- response = HtmlResponse("http://example.com/index.html", body=html)
- lx = SgmlLinkExtractor(attrs="href")
- self.assertEqual(lx.extract_links(response), [
- Link(url='http://example.com/sample1.html', text=u''),
- ])
-
- def test_link_nofollow(self):
- html = """
- Printer-friendly page
- About us
- Something
- """
- response = HtmlResponse("http://example.org/page.html", body=html)
- lx = SgmlLinkExtractor()
- self.assertEqual([link for link in lx.extract_links(response)], [
- Link(url='http://example.org/page.html?action=print', text=u'Printer-friendly page', nofollow=True),
- Link(url='http://example.org/about.html', text=u'About us', nofollow=False),
- Link(url='http://google.com/something', text=u'Something', nofollow=True),
- ])
-
-
-class RegexLinkExtractorTestCase(unittest.TestCase):
- # XXX: RegexLinkExtractor is not deprecated yet, but it must be rewritten
- # not to depend on SgmlLinkExractor. Its speed is also much worse
- # than it should be.
-
- def setUp(self):
- body = get_testdata('link_extractor', 'sgml_linkextractor.html')
- self.response = HtmlResponse(url='http://example.com/index', body=body)
-
- def test_extraction(self):
- # Default arguments
- lx = RegexLinkExtractor()
- self.assertEqual(lx.extract_links(self.response),
- [Link(url='http://example.com/sample2.html', text=u'sample 2'),
- Link(url='http://example.com/sample3.html', text=u'sample 3 text'),
- Link(url='http://example.com/sample3.html#foo', text=u'sample 3 repetition with fragment'),
- Link(url='http://www.google.com/something', text=u''),
- Link(url='http://example.com/innertag.html', text=u'inner tag'),])
-
- def test_link_wrong_href(self):
- html = """
- Item 1
- Item 2
- Item 3
- """
- response = HtmlResponse("http://example.org/index.html", body=html)
- lx = RegexLinkExtractor()
- self.assertEqual([link for link in lx.extract_links(response)], [
- Link(url='http://example.org/item1.html', text=u'Item 1', nofollow=False),
- Link(url='http://example.org/item3.html', text=u'Item 3', nofollow=False),
- ])
-
- def test_html_base_href(self):
- html = """
-
-
-
-
-
-
-
-
- """
- response = HtmlResponse("http://a.com/", body=html)
- lx = RegexLinkExtractor()
- self.assertEqual([link for link in lx.extract_links(response)], [
- Link(url='http://b.com/test.html', text=u'', nofollow=False),
- ])
-
- @unittest.expectedFailure
- def test_extraction(self):
- # RegexLinkExtractor doesn't parse URLs with leading/trailing
- # whitespaces correctly.
- super(RegexLinkExtractorTestCase, self).test_extraction()
diff --git a/tests/test_loader.py b/tests/test_loader.py
index 3b5714058..579a85ff6 100644
--- a/tests/test_loader.py
+++ b/tests/test_loader.py
@@ -1,13 +1,13 @@
-import unittest
-import six
from functools import partial
+import unittest
-from scrapy.loader import ItemLoader
-from scrapy.loader.processors import Join, Identity, TakeFirst, \
- Compose, MapCompose, SelectJmes
-from scrapy.item import Item, Field
-from scrapy.selector import Selector
from scrapy.http import HtmlResponse
+from scrapy.item import Item, Field
+from scrapy.loader import ItemLoader
+from scrapy.loader.processors import (Compose, Identity, Join,
+ MapCompose, SelectJmes, TakeFirst)
+from scrapy.selector import Selector
+
# test items
class NameItem(Item):
@@ -61,7 +61,7 @@ class BasicItemLoaderTest(unittest.TestCase):
il.add_value('name', u'marta')
item = il.load_item()
assert item is i
- self.assertEqual(item['summary'], u'lala')
+ self.assertEqual(item['summary'], [u'lala'])
self.assertEqual(item['name'], [u'marta'])
def test_load_item_using_custom_loader(self):
@@ -155,7 +155,7 @@ class BasicItemLoaderTest(unittest.TestCase):
def test_get_value(self):
il = NameItemLoader()
- self.assertEqual(u'FOO', il.get_value([u'foo', u'bar'], TakeFirst(), six.text_type.upper))
+ self.assertEqual(u'FOO', il.get_value([u'foo', u'bar'], TakeFirst(), str.upper))
self.assertEqual([u'foo', u'bar'], il.get_value([u'name:foo', u'name:bar'], re=u'name:(.*)$'))
self.assertEqual(u'foo', il.get_value([u'name:foo', u'name:bar'], TakeFirst(), re=u'name:(.*)$'))
@@ -256,7 +256,7 @@ class BasicItemLoaderTest(unittest.TestCase):
def test_extend_custom_input_processors(self):
class ChildItemLoader(TestItemLoader):
- name_in = MapCompose(TestItemLoader.name_in, six.text_type.swapcase)
+ name_in = MapCompose(TestItemLoader.name_in, str.swapcase)
il = ChildItemLoader()
il.add_value('name', u'marta')
@@ -264,7 +264,7 @@ class BasicItemLoaderTest(unittest.TestCase):
def test_extend_default_input_processors(self):
class ChildDefaultedItemLoader(DefaultedItemLoader):
- name_in = MapCompose(DefaultedItemLoader.default_input_processor, six.text_type.swapcase)
+ name_in = MapCompose(DefaultedItemLoader.default_input_processor, str.swapcase)
il = ChildDefaultedItemLoader()
il.add_value('name', u'marta')
@@ -419,6 +419,256 @@ class BasicItemLoaderTest(unittest.TestCase):
self.assertEqual(item['url'], u'rabbit.hole')
self.assertEqual(item['summary'], u'rabbithole')
+ def test_error_input_processor(self):
+ class TestItem(Item):
+ name = Field()
+
+ class TestItemLoader(ItemLoader):
+ default_item_class = TestItem
+ name_in = MapCompose(float)
+
+ il = TestItemLoader()
+ self.assertRaises(ValueError, il.add_value, 'name',
+ [u'marta', u'other'])
+
+ def test_error_output_processor(self):
+ class TestItem(Item):
+ name = Field()
+
+ class TestItemLoader(ItemLoader):
+ default_item_class = TestItem
+ name_out = Compose(Join(), float)
+
+ il = TestItemLoader()
+ il.add_value('name', u'marta')
+ with self.assertRaises(ValueError):
+ il.load_item()
+
+ def test_error_processor_as_argument(self):
+ class TestItem(Item):
+ name = Field()
+
+ class TestItemLoader(ItemLoader):
+ default_item_class = TestItem
+
+ il = TestItemLoader()
+ self.assertRaises(ValueError, il.add_value, 'name',
+ [u'marta', u'other'], Compose(float))
+
+
+class InitializationTestMixin(object):
+
+ item_class = None
+
+ def test_keep_single_value(self):
+ """Loaded item should contain values from the initial item"""
+ input_item = self.item_class(name='foo')
+ il = ItemLoader(item=input_item)
+ loaded_item = il.load_item()
+ self.assertIsInstance(loaded_item, self.item_class)
+ self.assertEqual(dict(loaded_item), {'name': ['foo']})
+
+ def test_keep_list(self):
+ """Loaded item should contain values from the initial item"""
+ input_item = self.item_class(name=['foo', 'bar'])
+ il = ItemLoader(item=input_item)
+ loaded_item = il.load_item()
+ self.assertIsInstance(loaded_item, self.item_class)
+ self.assertEqual(dict(loaded_item), {'name': ['foo', 'bar']})
+
+ def test_add_value_singlevalue_singlevalue(self):
+ """Values added after initialization should be appended"""
+ input_item = self.item_class(name='foo')
+ il = ItemLoader(item=input_item)
+ il.add_value('name', 'bar')
+ loaded_item = il.load_item()
+ self.assertIsInstance(loaded_item, self.item_class)
+ self.assertEqual(dict(loaded_item), {'name': ['foo', 'bar']})
+
+ def test_add_value_singlevalue_list(self):
+ """Values added after initialization should be appended"""
+ input_item = self.item_class(name='foo')
+ il = ItemLoader(item=input_item)
+ il.add_value('name', ['item', 'loader'])
+ loaded_item = il.load_item()
+ self.assertIsInstance(loaded_item, self.item_class)
+ self.assertEqual(dict(loaded_item), {'name': ['foo', 'item', 'loader']})
+
+ def test_add_value_list_singlevalue(self):
+ """Values added after initialization should be appended"""
+ input_item = self.item_class(name=['foo', 'bar'])
+ il = ItemLoader(item=input_item)
+ il.add_value('name', 'qwerty')
+ loaded_item = il.load_item()
+ self.assertIsInstance(loaded_item, self.item_class)
+ self.assertEqual(dict(loaded_item), {'name': ['foo', 'bar', 'qwerty']})
+
+ def test_add_value_list_list(self):
+ """Values added after initialization should be appended"""
+ input_item = self.item_class(name=['foo', 'bar'])
+ il = ItemLoader(item=input_item)
+ il.add_value('name', ['item', 'loader'])
+ loaded_item = il.load_item()
+ self.assertIsInstance(loaded_item, self.item_class)
+ self.assertEqual(dict(loaded_item), {'name': ['foo', 'bar', 'item', 'loader']})
+
+ def test_get_output_value_singlevalue(self):
+ """Getting output value must not remove value from item"""
+ input_item = self.item_class(name='foo')
+ il = ItemLoader(item=input_item)
+ self.assertEqual(il.get_output_value('name'), ['foo'])
+ loaded_item = il.load_item()
+ self.assertIsInstance(loaded_item, self.item_class)
+ self.assertEqual(loaded_item, dict({'name': ['foo']}))
+
+ def test_get_output_value_list(self):
+ """Getting output value must not remove value from item"""
+ input_item = self.item_class(name=['foo', 'bar'])
+ il = ItemLoader(item=input_item)
+ self.assertEqual(il.get_output_value('name'), ['foo', 'bar'])
+ loaded_item = il.load_item()
+ self.assertIsInstance(loaded_item, self.item_class)
+ self.assertEqual(loaded_item, dict({'name': ['foo', 'bar']}))
+
+ def test_values_single(self):
+ """Values from initial item must be added to loader._values"""
+ input_item = self.item_class(name='foo')
+ il = ItemLoader(item=input_item)
+ self.assertEqual(il._values.get('name'), ['foo'])
+
+ def test_values_list(self):
+ """Values from initial item must be added to loader._values"""
+ input_item = self.item_class(name=['foo', 'bar'])
+ il = ItemLoader(item=input_item)
+ self.assertEqual(il._values.get('name'), ['foo', 'bar'])
+
+
+class InitializationFromDictTest(InitializationTestMixin, unittest.TestCase):
+ item_class = dict
+
+
+class InitializationFromItemTest(InitializationTestMixin, unittest.TestCase):
+ item_class = NameItem
+
+
+class BaseNoInputReprocessingLoader(ItemLoader):
+ title_in = MapCompose(str.upper)
+ title_out = TakeFirst()
+
+
+class NoInputReprocessingDictLoader(BaseNoInputReprocessingLoader):
+ default_item_class = dict
+
+
+class NoInputReprocessingFromDictTest(unittest.TestCase):
+ """
+ Loaders initialized from loaded items must not reprocess fields (dict instances)
+ """
+ def test_avoid_reprocessing_with_initial_values_single(self):
+ il = NoInputReprocessingDictLoader(item=dict(title='foo'))
+ il_loaded = il.load_item()
+ self.assertEqual(il_loaded, dict(title='foo'))
+ self.assertEqual(NoInputReprocessingDictLoader(item=il_loaded).load_item(), dict(title='foo'))
+
+ def test_avoid_reprocessing_with_initial_values_list(self):
+ il = NoInputReprocessingDictLoader(item=dict(title=['foo', 'bar']))
+ il_loaded = il.load_item()
+ self.assertEqual(il_loaded, dict(title='foo'))
+ self.assertEqual(NoInputReprocessingDictLoader(item=il_loaded).load_item(), dict(title='foo'))
+
+ def test_avoid_reprocessing_without_initial_values_single(self):
+ il = NoInputReprocessingDictLoader()
+ il.add_value('title', 'foo')
+ il_loaded = il.load_item()
+ self.assertEqual(il_loaded, dict(title='FOO'))
+ self.assertEqual(NoInputReprocessingDictLoader(item=il_loaded).load_item(), dict(title='FOO'))
+
+ def test_avoid_reprocessing_without_initial_values_list(self):
+ il = NoInputReprocessingDictLoader()
+ il.add_value('title', ['foo', 'bar'])
+ il_loaded = il.load_item()
+ self.assertEqual(il_loaded, dict(title='FOO'))
+ self.assertEqual(NoInputReprocessingDictLoader(item=il_loaded).load_item(), dict(title='FOO'))
+
+
+class NoInputReprocessingItem(Item):
+ title = Field()
+
+
+class NoInputReprocessingItemLoader(BaseNoInputReprocessingLoader):
+ default_item_class = NoInputReprocessingItem
+
+
+class NoInputReprocessingFromItemTest(unittest.TestCase):
+ """
+ Loaders initialized from loaded items must not reprocess fields (BaseItem instances)
+ """
+ def test_avoid_reprocessing_with_initial_values_single(self):
+ il = NoInputReprocessingItemLoader(item=NoInputReprocessingItem(title='foo'))
+ il_loaded = il.load_item()
+ self.assertEqual(il_loaded, {'title': 'foo'})
+ self.assertEqual(NoInputReprocessingItemLoader(item=il_loaded).load_item(), {'title': 'foo'})
+
+ def test_avoid_reprocessing_with_initial_values_list(self):
+ il = NoInputReprocessingItemLoader(item=NoInputReprocessingItem(title=['foo', 'bar']))
+ il_loaded = il.load_item()
+ self.assertEqual(il_loaded, {'title': 'foo'})
+ self.assertEqual(NoInputReprocessingItemLoader(item=il_loaded).load_item(), {'title': 'foo'})
+
+ def test_avoid_reprocessing_without_initial_values_single(self):
+ il = NoInputReprocessingItemLoader()
+ il.add_value('title', 'FOO')
+ il_loaded = il.load_item()
+ self.assertEqual(il_loaded, {'title': 'FOO'})
+ self.assertEqual(NoInputReprocessingItemLoader(item=il_loaded).load_item(), {'title': 'FOO'})
+
+ def test_avoid_reprocessing_without_initial_values_list(self):
+ il = NoInputReprocessingItemLoader()
+ il.add_value('title', ['foo', 'bar'])
+ il_loaded = il.load_item()
+ self.assertEqual(il_loaded, {'title': 'FOO'})
+ self.assertEqual(NoInputReprocessingItemLoader(item=il_loaded).load_item(), {'title': 'FOO'})
+
+
+class TestOutputProcessorDict(unittest.TestCase):
+ def test_output_processor(self):
+
+ class TempDict(dict):
+ def __init__(self, *args, **kwargs):
+ super(TempDict, self).__init__(self, *args, **kwargs)
+ self.setdefault('temp', 0.3)
+
+ class TempLoader(ItemLoader):
+ default_item_class = TempDict
+ default_input_processor = Identity()
+ default_output_processor = Compose(TakeFirst())
+
+ loader = TempLoader()
+ item = loader.load_item()
+ self.assertIsInstance(item, TempDict)
+ self.assertEqual(dict(item), {'temp': 0.3})
+
+
+class TestOutputProcessorItem(unittest.TestCase):
+ def test_output_processor(self):
+
+ class TempItem(Item):
+ temp = Field()
+
+ def __init__(self, *args, **kwargs):
+ super(TempItem, self).__init__(self, *args, **kwargs)
+ self.setdefault('temp', 0.3)
+
+ class TempLoader(ItemLoader):
+ default_item_class = TempItem
+ default_input_processor = Identity()
+ default_output_processor = Compose(TakeFirst())
+
+ loader = TempLoader()
+ item = loader.load_item()
+ self.assertIsInstance(item, TempItem)
+ self.assertEqual(dict(item), {'temp': 0.3})
+
class ProcessorsTest(unittest.TestCase):
@@ -437,7 +687,7 @@ class ProcessorsTest(unittest.TestCase):
self.assertRaises(TypeError, proc, [None, '', 'hello', 'world'])
self.assertEqual(proc(['', 'hello', 'world']), u' hello world')
self.assertEqual(proc(['hello', 'world']), u'hello world')
- self.assertIsInstance(proc(['hello', 'world']), six.text_type)
+ self.assertIsInstance(proc(['hello', 'world']), str)
def test_compose(self):
proc = Compose(lambda v: v[0], str.upper)
@@ -445,13 +695,22 @@ class ProcessorsTest(unittest.TestCase):
proc = Compose(str.upper)
self.assertEqual(proc(None), None)
proc = Compose(str.upper, stop_on_none=False)
- self.assertRaises(TypeError, proc, None)
+ self.assertRaises(ValueError, proc, None)
+ proc = Compose(str.upper, lambda x: x + 1)
+ self.assertRaises(ValueError, proc, 'hello')
def test_mapcompose(self):
- filter_world = lambda x: None if x == 'world' else x
- proc = MapCompose(filter_world, six.text_type.upper)
+ def filter_world(x):
+ return None if x == 'world' else x
+ proc = MapCompose(filter_world, str.upper)
self.assertEqual(proc([u'hello', u'world', u'this', u'is', u'scrapy']),
[u'HELLO', u'THIS', u'IS', u'SCRAPY'])
+ proc = MapCompose(filter_world, str.upper)
+ self.assertEqual(proc(None), [])
+ proc = MapCompose(filter_world, str.upper)
+ self.assertRaises(ValueError, proc, [1])
+ proc = MapCompose(filter_world, lambda x: x + 1)
+ self.assertRaises(ValueError, proc, 'hello')
class SelectortemLoaderTest(unittest.TestCase):
@@ -466,11 +725,11 @@ class SelectortemLoaderTest(unittest.TestCase):
""")
- def test_constructor(self):
+ def test_init_method(self):
l = TestItemLoader()
self.assertEqual(l.selector, None)
- def test_constructor_errors(self):
+ def test_init_method_errors(self):
l = TestItemLoader()
self.assertRaises(RuntimeError, l.add_xpath, 'url', '//a/@href')
self.assertRaises(RuntimeError, l.replace_xpath, 'url', '//a/@href')
@@ -479,7 +738,7 @@ class SelectortemLoaderTest(unittest.TestCase):
self.assertRaises(RuntimeError, l.replace_css, 'name', '#name::text')
self.assertRaises(RuntimeError, l.get_css, '#name::text')
- def test_constructor_with_selector(self):
+ def test_init_method_with_selector(self):
sel = Selector(text=u"marta
")
l = TestItemLoader(selector=sel)
self.assertIs(l.selector, sel)
@@ -487,7 +746,7 @@ class SelectortemLoaderTest(unittest.TestCase):
l.add_xpath('name', '//div/text()')
self.assertEqual(l.get_output_value('name'), [u'Marta'])
- def test_constructor_with_selector_css(self):
+ def test_init_method_with_selector_css(self):
sel = Selector(text=u"marta
")
l = TestItemLoader(selector=sel)
self.assertIs(l.selector, sel)
@@ -495,14 +754,14 @@ class SelectortemLoaderTest(unittest.TestCase):
l.add_css('name', 'div::text')
self.assertEqual(l.get_output_value('name'), [u'Marta'])
- def test_constructor_with_response(self):
+ def test_init_method_with_response(self):
l = TestItemLoader(response=self.response)
self.assertTrue(l.selector)
l.add_xpath('name', '//div/text()')
self.assertEqual(l.get_output_value('name'), [u'Marta'])
- def test_constructor_with_response_css(self):
+ def test_init_method_with_response_css(self):
l = TestItemLoader(response=self.response)
self.assertTrue(l.selector)
@@ -590,7 +849,7 @@ class SelectortemLoaderTest(unittest.TestCase):
self.assertEqual(l.get_css(['p::text', 'div::text']), [u'paragraph', 'marta'])
self.assertEqual(l.get_css(['a::attr(href)', 'img::attr(src)']),
- [u'http://www.scrapy.org', u'/images/logo.png'])
+ [u'http://www.scrapy.org', u'/images/logo.png'])
def test_replace_css_multi_fields(self):
l = TestItemLoader(response=self.response)
@@ -609,7 +868,7 @@ class SelectortemLoaderTest(unittest.TestCase):
self.assertTrue(l.selector)
l.add_css('url', 'a::attr(href)')
self.assertEqual(l.get_output_value('url'), [u'http://www.scrapy.org'])
- l.replace_css('url', 'a::attr(href)', re='http://www\.(.+)')
+ l.replace_css('url', 'a::attr(href)', re=r'http://www\.(.+)')
self.assertEqual(l.get_output_value('url'), [u'scrapy.org'])
@@ -634,11 +893,11 @@ class SubselectorLoaderTest(unittest.TestCase):
nl = l.nested_xpath("//header")
nl.add_xpath('name', 'div/text()')
nl.add_css('name_div', '#id')
- nl.add_value('name_value', nl.selector.xpath('div[@id = "id"]/text()').extract())
+ nl.add_value('name_value', nl.selector.xpath('div[@id = "id"]/text()').getall())
self.assertEqual(l.get_output_value('name'), [u'marta'])
self.assertEqual(l.get_output_value('name_div'), [u'marta
'])
- self.assertEqual(l.get_output_value('name_value'), [u'marta'])
+ self.assertEqual(l.get_output_value('name_value'), [u'marta'])
self.assertEqual(l.get_output_value('name'), nl.get_output_value('name'))
self.assertEqual(l.get_output_value('name_div'), nl.get_output_value('name_div'))
@@ -649,11 +908,11 @@ class SubselectorLoaderTest(unittest.TestCase):
nl = l.nested_css("header")
nl.add_xpath('name', 'div/text()')
nl.add_css('name_div', '#id')
- nl.add_value('name_value', nl.selector.xpath('div[@id = "id"]/text()').extract())
+ nl.add_value('name_value', nl.selector.xpath('div[@id = "id"]/text()').getall())
self.assertEqual(l.get_output_value('name'), [u'marta'])
self.assertEqual(l.get_output_value('name_div'), [u'marta
'])
- self.assertEqual(l.get_output_value('name_value'), [u'marta'])
+ self.assertEqual(l.get_output_value('name_value'), [u'marta'])
self.assertEqual(l.get_output_value('name'), nl.get_output_value('name'))
self.assertEqual(l.get_output_value('name_div'), nl.get_output_value('name_div'))
@@ -709,28 +968,76 @@ 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)
+ )
+
+
+# Functions as processors
+
+def function_processor_strip(iterable):
+ return [x.strip() for x in iterable]
+
+
+def function_processor_upper(iterable):
+ return [x.upper() for x in iterable]
+
+
+class FunctionProcessorItem(Item):
+ foo = Field(
+ input_processor=function_processor_strip,
+ output_processor=function_processor_upper,
+ )
+
+
+class FunctionProcessorItemLoader(ItemLoader):
+ default_item_class = FunctionProcessorItem
+
+
+class FunctionProcessorDictLoader(ItemLoader):
+ default_item_class = dict
+ foo_in = function_processor_strip
+ foo_out = function_processor_upper
+
+
+class FunctionProcessorTestCase(unittest.TestCase):
+
+ def test_processor_defined_in_item(self):
+ lo = FunctionProcessorItemLoader()
+ lo.add_value('foo', ' bar ')
+ lo.add_value('foo', [' asdf ', ' qwerty '])
+ self.assertEqual(
+ dict(lo.load_item()),
+ {'foo': ['BAR', 'ASDF', 'QWERTY']}
+ )
+
+ def test_processor_defined_in_item_loader(self):
+ lo = FunctionProcessorDictLoader()
+ lo.add_value('foo', ' bar ')
+ lo.add_value('foo', [' asdf ', ' qwerty '])
+ self.assertEqual(
+ dict(lo.load_item()),
+ {'foo': ['BAR', 'ASDF', 'QWERTY']}
+ )
if __name__ == "__main__":
diff --git a/tests/test_logformatter.py b/tests/test_logformatter.py
index 94e6c9fde..7d8c6ec7f 100644
--- a/tests/test_logformatter.py
+++ b/tests/test_logformatter.py
@@ -1,10 +1,17 @@
import unittest
-import six
-from scrapy.spiders import Spider
+from testfixtures import LogCapture
+from twisted.internet import defer
+from twisted.trial.unittest import TestCase as TwistedTestCase
+
+from scrapy.crawler import CrawlerRunner
+from scrapy.exceptions import DropItem
from scrapy.http import Request, Response
from scrapy.item import Item, Field
from scrapy.logformatter import LogFormatter
+from scrapy.spiders import Spider
+from tests.mockserver import MockServer
+from tests.spiders import ItemSpider
class CustomItem(Item):
@@ -15,13 +22,13 @@ class CustomItem(Item):
return "name: %s" % self['name']
-class LoggingContribTest(unittest.TestCase):
+class LogFormatterTestCase(unittest.TestCase):
def setUp(self):
self.formatter = LogFormatter()
self.spider = Spider('default')
- def test_crawled(self):
+ def test_crawled_with_referer(self):
req = Request("http://www.example.com")
res = Response("http://www.example.com")
logkws = self.formatter.crawled(req, res, self.spider)
@@ -29,6 +36,7 @@ class LoggingContribTest(unittest.TestCase):
self.assertEqual(logline,
"Crawled (200) (referer: None)")
+ def test_crawled_without_referer(self):
req = Request("http://www.example.com", headers={'referer': 'http://example.com'})
res = Response("http://www.example.com", flags=['cached'])
logkws = self.formatter.crawled(req, res, self.spider)
@@ -37,7 +45,7 @@ class LoggingContribTest(unittest.TestCase):
"Crawled (200) (referer: http://example.com) ['cached']")
def test_flags_in_request(self):
- req = Request("http://www.example.com", flags=['test','flag'])
+ req = Request("http://www.example.com", flags=['test', 'flag'])
res = Response("http://www.example.com")
logkws = self.formatter.crawled(req, res, self.spider)
logline = logkws['msg'] % logkws['args']
@@ -51,9 +59,19 @@ class LoggingContribTest(unittest.TestCase):
logkws = self.formatter.dropped(item, exception, response, self.spider)
logline = logkws['msg'] % logkws['args']
lines = logline.splitlines()
- assert all(isinstance(x, six.text_type) for x in lines)
+ assert all(isinstance(x, str) for x in lines)
self.assertEqual(lines, [u"Dropped: \u2018", '{}'])
+ def test_error(self):
+ # In practice, the complete traceback is shown by passing the
+ # 'exc_info' argument to the logging function
+ item = {'key': 'value'}
+ exception = Exception()
+ response = Response("http://www.example.com")
+ logkws = self.formatter.error(item, exception, response, self.spider)
+ logline = logkws['msg'] % logkws['args']
+ self.assertEqual(logline, u"'Error processing {'key': 'value'}'")
+
def test_scraped(self):
item = CustomItem()
item['name'] = u'\xa3'
@@ -61,32 +79,109 @@ class LoggingContribTest(unittest.TestCase):
logkws = self.formatter.scraped(item, response, self.spider)
logline = logkws['msg'] % logkws['args']
lines = logline.splitlines()
- assert all(isinstance(x, six.text_type) for x in lines)
+ assert all(isinstance(x, str) for x in lines)
self.assertEqual(lines, [u"Scraped from <200 http://www.example.com>", u'name: \xa3'])
class LogFormatterSubclass(LogFormatter):
def crawled(self, request, response, spider):
- kwargs = super(LogFormatterSubclass, self).crawled(
- request, response, spider)
+ kwargs = super(LogFormatterSubclass, self).crawled(request, response, spider)
CRAWLEDMSG = (
- u"Crawled (%(status)s) %(request)s (referer: "
- u"%(referer)s)%(flags)s"
+ u"Crawled (%(status)s) %(request)s (referer: %(referer)s) %(flags)s"
)
+ log_args = kwargs['args']
+ log_args['flags'] = str(request.flags)
return {
'level': kwargs['level'],
'msg': CRAWLEDMSG,
- 'args': kwargs['args']
+ 'args': log_args,
}
-class LogformatterSubclassTest(LoggingContribTest):
+class LogformatterSubclassTest(LogFormatterTestCase):
def setUp(self):
self.formatter = LogFormatterSubclass()
self.spider = Spider('default')
+ def test_crawled_with_referer(self):
+ req = Request("http://www.example.com")
+ res = Response("http://www.example.com")
+ logkws = self.formatter.crawled(req, res, self.spider)
+ logline = logkws['msg'] % logkws['args']
+ self.assertEqual(logline,
+ "Crawled (200) (referer: None) []")
+
+ def test_crawled_without_referer(self):
+ req = Request("http://www.example.com", headers={'referer': 'http://example.com'}, flags=['cached'])
+ res = Response("http://www.example.com")
+ logkws = self.formatter.crawled(req, res, self.spider)
+ logline = logkws['msg'] % logkws['args']
+ self.assertEqual(logline,
+ "Crawled (200) (referer: http://example.com) ['cached']")
+
def test_flags_in_request(self):
- pass
+ req = Request("http://www.example.com", flags=['test', 'flag'])
+ res = Response("http://www.example.com")
+ logkws = self.formatter.crawled(req, res, self.spider)
+ logline = logkws['msg'] % logkws['args']
+ self.assertEqual(logline, "Crawled (200) (referer: None) ['test', 'flag']")
+
+
+class SkipMessagesLogFormatter(LogFormatter):
+ def crawled(self, *args, **kwargs):
+ return None
+
+ def scraped(self, *args, **kwargs):
+ return None
+
+ def dropped(self, *args, **kwargs):
+ return None
+
+
+class DropSomeItemsPipeline(object):
+ drop = True
+
+ def process_item(self, item, spider):
+ if self.drop:
+ self.drop = False
+ raise DropItem("Ignoring item")
+ else:
+ self.drop = True
+
+
+class ShowOrSkipMessagesTestCase(TwistedTestCase):
+ def setUp(self):
+ self.mockserver = MockServer()
+ self.mockserver.__enter__()
+ self.base_settings = {
+ 'LOG_LEVEL': 'DEBUG',
+ 'ITEM_PIPELINES': {
+ __name__ + '.DropSomeItemsPipeline': 300,
+ },
+ }
+
+ def tearDown(self):
+ self.mockserver.__exit__(None, None, None)
+
+ @defer.inlineCallbacks
+ def test_show_messages(self):
+ crawler = CrawlerRunner(self.base_settings).create_crawler(ItemSpider)
+ with LogCapture() as lc:
+ yield crawler.crawl(mockserver=self.mockserver)
+ self.assertIn("Scraped from <200 http://127.0.0.1:", str(lc))
+ self.assertIn("Crawled (200) = 6)
@defer.inlineCallbacks
def test_https_connect_tunnel_error(self):
crawler = get_crawler(SimpleSpider)
@@ -73,33 +90,24 @@ class ProxyConnectTestCase(TestCase):
@defer.inlineCallbacks
def test_https_tunnel_auth_error(self):
- os.environ['https_proxy'] = 'http://wrong:wronger@localhost:8888'
+ os.environ['https_proxy'] = _wrong_credentials(os.environ['https_proxy'])
crawler = get_crawler(SimpleSpider)
with LogCapture() as l:
- yield crawler.crawl("https://localhost:8999/status?n=200")
+ yield crawler.crawl(self.mockserver.url("/status?n=200", is_secure=True))
# The proxy returns a 407 error code but it does not reach the client;
# he just sees a TunnelError.
self._assert_got_tunnel_error(l)
- os.environ['https_proxy'] = 'http://scrapy:scrapy@localhost:8888'
@defer.inlineCallbacks
def test_https_tunnel_without_leak_proxy_authorization_header(self):
- request = Request("https://localhost:8999/echo")
+ request = Request(self.mockserver.url("/echo", is_secure=True))
crawler = get_crawler(SingleRequestSpider)
with LogCapture() as l:
yield crawler.crawl(seed=request)
self._assert_got_response_code(200, l)
- echo = json.loads(crawler.spider.meta['responses'][0].body)
+ echo = json.loads(crawler.spider.meta['responses'][0].text)
self.assertTrue('Proxy-Authorization' not in echo['headers'])
- @defer.inlineCallbacks
- def test_https_noconnect_auth_error(self):
- os.environ['https_proxy'] = 'http://wrong:wronger@localhost:8888?noconnect'
- crawler = get_crawler(SimpleSpider)
- with LogCapture() as l:
- yield crawler.crawl("https://localhost:8999/status?n=200")
- self._assert_got_response_code(407, l)
-
def _assert_got_response_code(self, code, log):
print(log)
self.assertEqual(str(log).count('Crawled (%d)' % code), 1)
diff --git a/tests/test_pydispatch_deprecated.py b/tests/test_pydispatch_deprecated.py
deleted file mode 100644
index 6d3237fe1..000000000
--- a/tests/test_pydispatch_deprecated.py
+++ /dev/null
@@ -1,12 +0,0 @@
-import unittest
-import warnings
-from six.moves import reload_module
-
-
-class DeprecatedPydispatchTest(unittest.TestCase):
- def test_import_xlib_pydispatch_show_warning(self):
- with warnings.catch_warnings(record=True) as w:
- from scrapy.xlib import pydispatch
- reload_module(pydispatch)
- self.assertIn('Importing from scrapy.xlib.pydispatch is deprecated',
- str(w[0].message))
diff --git a/tests/test_request_cb_kwargs.py b/tests/test_request_cb_kwargs.py
new file mode 100644
index 000000000..a5cdc0de0
--- /dev/null
+++ b/tests/test_request_cb_kwargs.py
@@ -0,0 +1,163 @@
+from testfixtures import LogCapture
+from twisted.internet import defer
+from twisted.trial.unittest import TestCase
+
+from scrapy.http import Request
+from scrapy.crawler import CrawlerRunner
+from tests.spiders import MockServerSpider
+from tests.mockserver import MockServer
+
+
+class InjectArgumentsDownloaderMiddleware(object):
+ """
+ Make sure downloader middlewares are able to update the keyword arguments
+ """
+ def process_request(self, request, spider):
+ if request.callback.__name__ == 'parse_downloader_mw':
+ request.cb_kwargs['from_process_request'] = True
+ return None
+
+ def process_response(self, request, response, spider):
+ if request.callback.__name__ == 'parse_downloader_mw':
+ request.cb_kwargs['from_process_response'] = True
+ return response
+
+
+class InjectArgumentsSpiderMiddleware(object):
+ """
+ Make sure spider middlewares are able to update the keyword arguments
+ """
+ def process_start_requests(self, start_requests, spider):
+ for request in start_requests:
+ if request.callback.__name__ == 'parse_spider_mw':
+ request.cb_kwargs['from_process_start_requests'] = True
+ yield request
+
+ def process_spider_input(self, response, spider):
+ request = response.request
+ if request.callback.__name__ == 'parse_spider_mw':
+ request.cb_kwargs['from_process_spider_input'] = True
+ return None
+
+ def process_spider_output(self, response, result, spider):
+ for element in result:
+ if isinstance(element, Request) and element.callback.__name__ == 'parse_spider_mw_2':
+ element.cb_kwargs['from_process_spider_output'] = True
+ yield element
+
+
+class KeywordArgumentsSpider(MockServerSpider):
+ name = 'kwargs'
+ custom_settings = {
+ 'DOWNLOADER_MIDDLEWARES': {
+ __name__ + '.InjectArgumentsDownloaderMiddleware': 750,
+ },
+ 'SPIDER_MIDDLEWARES': {
+ __name__ + '.InjectArgumentsSpiderMiddleware': 750,
+ },
+ }
+
+ checks = list()
+
+ def start_requests(self):
+ data = {'key': 'value', 'number': 123}
+ 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)
+ 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)
+ yield Request(self.mockserver.url('/downloader_mw'), self.parse_downloader_mw)
+ yield Request(self.mockserver.url('/spider_mw'), self.parse_spider_mw)
+
+ def parse_first(self, response, key, number):
+ 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,
+ cb_kwargs={'new_key': 'new_value'})
+
+ def parse_second(self, response, new_key):
+ 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.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.append(kwargs == {})
+ self.crawler.stats.inc_value('boolean_checks')
+
+ def parse_no_kwargs(self, response):
+ 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'
+ """
+
+ def parse_downloader_mw(self, response, from_process_request, from_process_response):
+ self.checks.append(bool(from_process_request))
+ self.checks.append(bool(from_process_response))
+ self.crawler.stats.inc_value('boolean_checks', 2)
+
+ def parse_spider_mw(self, response, from_process_spider_input, from_process_start_requests):
+ self.checks.append(bool(from_process_spider_input))
+ self.checks.append(bool(from_process_start_requests))
+ self.crawler.stats.inc_value('boolean_checks', 2)
+ return Request(self.mockserver.url('/spider_mw_2'), self.parse_spider_mw_2)
+
+ def parse_spider_mw_2(self, response, from_process_spider_output):
+ self.checks.append(bool(from_process_spider_output))
+ self.crawler.stats.inc_value('boolean_checks', 1)
+
+
+class CallbackKeywordArgumentsTestCase(TestCase):
+
+ maxDiff = None
+
+ def setUp(self):
+ self.mockserver = MockServer()
+ self.mockserver.__enter__()
+ self.runner = CrawlerRunner()
+
+ def tearDown(self):
+ self.mockserver.__exit__(None, None, None)
+
+ @defer.inlineCallbacks
+ def test_callback_kwargs(self):
+ crawler = self.runner.create_crawler(KeywordArgumentsSpider)
+ 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)
+ self.assertEqual(str(exceptions['takes_more'].exc_info[1]), "parse_takes_more() missing 1 required positional argument: 'other'")
diff --git a/tests/test_responsetypes.py b/tests/test_responsetypes.py
index f89042b3d..d5a3371ab 100644
--- a/tests/test_responsetypes.py
+++ b/tests/test_responsetypes.py
@@ -4,6 +4,7 @@ from scrapy.responsetypes import responsetypes
from scrapy.http import Response, TextResponse, XmlResponse, HtmlResponse, Headers
+
class ResponseTypesTest(unittest.TestCase):
def test_from_filename(self):
diff --git a/tests/test_robotstxt_interface.py b/tests/test_robotstxt_interface.py
new file mode 100644
index 000000000..24aaaf7ec
--- /dev/null
+++ b/tests/test_robotstxt_interface.py
@@ -0,0 +1,162 @@
+# coding=utf-8
+from twisted.trial import unittest
+
+
+def reppy_available():
+ # check if reppy parser is installed
+ try:
+ from reppy.robots import Robots # noqa: F401
+ except ImportError:
+ return False
+ return True
+
+
+def rerp_available():
+ # check if robotexclusionrulesparser is installed
+ try:
+ from robotexclusionrulesparser import RobotExclusionRulesParser # noqa: F401
+ except ImportError:
+ return False
+ return True
+
+
+def protego_available():
+ # check if protego parser is installed
+ try:
+ from protego import Protego # noqa: F401
+ except ImportError:
+ return False
+ return True
+
+
+class BaseRobotParserTest:
+ def _setUp(self, parser_cls):
+ self.parser_cls = parser_cls
+
+ def test_allowed(self):
+ robotstxt_robotstxt_body = ("User-agent: * \n"
+ "Disallow: /disallowed \n"
+ "Allow: /allowed \n"
+ "Crawl-delay: 10".encode('utf-8'))
+ rp = self.parser_cls.from_crawler(crawler=None, robotstxt_body=robotstxt_robotstxt_body)
+ self.assertTrue(rp.allowed("https://www.site.local/allowed", "*"))
+ self.assertFalse(rp.allowed("https://www.site.local/disallowed", "*"))
+
+ def test_allowed_wildcards(self):
+ robotstxt_robotstxt_body = """User-agent: first
+ Disallow: /disallowed/*/end$
+
+ User-agent: second
+ Allow: /*allowed
+ Disallow: /
+ """.encode('utf-8')
+ rp = self.parser_cls.from_crawler(crawler=None, robotstxt_body=robotstxt_robotstxt_body)
+
+ self.assertTrue(rp.allowed("https://www.site.local/disallowed", "first"))
+ self.assertFalse(rp.allowed("https://www.site.local/disallowed/xyz/end", "first"))
+ self.assertFalse(rp.allowed("https://www.site.local/disallowed/abc/end", "first"))
+ self.assertTrue(rp.allowed("https://www.site.local/disallowed/xyz/endinglater", "first"))
+
+ self.assertTrue(rp.allowed("https://www.site.local/allowed", "second"))
+ self.assertTrue(rp.allowed("https://www.site.local/is_still_allowed", "second"))
+ self.assertTrue(rp.allowed("https://www.site.local/is_allowed_too", "second"))
+
+ def test_length_based_precedence(self):
+ robotstxt_robotstxt_body = ("User-agent: * \n"
+ "Disallow: / \n"
+ "Allow: /page".encode('utf-8'))
+ rp = self.parser_cls.from_crawler(crawler=None, robotstxt_body=robotstxt_robotstxt_body)
+ self.assertTrue(rp.allowed("https://www.site.local/page", "*"))
+
+ def test_order_based_precedence(self):
+ robotstxt_robotstxt_body = ("User-agent: * \n"
+ "Disallow: / \n"
+ "Allow: /page".encode('utf-8'))
+ rp = self.parser_cls.from_crawler(crawler=None, robotstxt_body=robotstxt_robotstxt_body)
+ self.assertFalse(rp.allowed("https://www.site.local/page", "*"))
+
+ def test_empty_response(self):
+ """empty response should equal 'allow all'"""
+ rp = self.parser_cls.from_crawler(crawler=None, robotstxt_body=b'')
+ self.assertTrue(rp.allowed("https://site.local/", "*"))
+ self.assertTrue(rp.allowed("https://site.local/", "chrome"))
+ self.assertTrue(rp.allowed("https://site.local/index.html", "*"))
+ self.assertTrue(rp.allowed("https://site.local/disallowed", "*"))
+
+ def test_garbage_response(self):
+ """garbage response should be discarded, equal 'allow all'"""
+ robotstxt_robotstxt_body = b'GIF89a\xd3\x00\xfe\x00\xa2'
+ rp = self.parser_cls.from_crawler(crawler=None, robotstxt_body=robotstxt_robotstxt_body)
+ self.assertTrue(rp.allowed("https://site.local/", "*"))
+ self.assertTrue(rp.allowed("https://site.local/", "chrome"))
+ self.assertTrue(rp.allowed("https://site.local/index.html", "*"))
+ self.assertTrue(rp.allowed("https://site.local/disallowed", "*"))
+
+ def test_unicode_url_and_useragent(self):
+ robotstxt_robotstxt_body = u"""
+ User-Agent: *
+ Disallow: /admin/
+ Disallow: /static/
+ # taken from https://en.wikipedia.org/robots.txt
+ Disallow: /wiki/K%C3%A4ytt%C3%A4j%C3%A4:
+ Disallow: /wiki/Käyttäjä:
+
+ User-Agent: UnicödeBöt
+ Disallow: /some/randome/page.html""".encode('utf-8')
+ rp = self.parser_cls.from_crawler(crawler=None, robotstxt_body=robotstxt_robotstxt_body)
+ self.assertTrue(rp.allowed("https://site.local/", "*"))
+ self.assertFalse(rp.allowed("https://site.local/admin/", "*"))
+ self.assertFalse(rp.allowed("https://site.local/static/", "*"))
+ self.assertTrue(rp.allowed("https://site.local/admin/", u"UnicödeBöt"))
+ self.assertFalse(rp.allowed("https://site.local/wiki/K%C3%A4ytt%C3%A4j%C3%A4:", "*"))
+ self.assertFalse(rp.allowed(u"https://site.local/wiki/Käyttäjä:", "*"))
+ self.assertTrue(rp.allowed("https://site.local/some/randome/page.html", "*"))
+ self.assertFalse(rp.allowed("https://site.local/some/randome/page.html", u"UnicödeBöt"))
+
+
+class PythonRobotParserTest(BaseRobotParserTest, unittest.TestCase):
+ def setUp(self):
+ from scrapy.robotstxt import PythonRobotParser
+ super(PythonRobotParserTest, self)._setUp(PythonRobotParser)
+
+ def test_length_based_precedence(self):
+ raise unittest.SkipTest("RobotFileParser does not support length based directives precedence.")
+
+ def test_allowed_wildcards(self):
+ raise unittest.SkipTest("RobotFileParser does not support wildcards.")
+
+
+class ReppyRobotParserTest(BaseRobotParserTest, unittest.TestCase):
+ if not reppy_available():
+ skip = "Reppy parser is not installed"
+
+ def setUp(self):
+ from scrapy.robotstxt import ReppyRobotParser
+ super(ReppyRobotParserTest, self)._setUp(ReppyRobotParser)
+
+ def test_order_based_precedence(self):
+ raise unittest.SkipTest("Reppy does not support order based directives precedence.")
+
+
+class RerpRobotParserTest(BaseRobotParserTest, unittest.TestCase):
+ if not rerp_available():
+ skip = "Rerp parser is not installed"
+
+ def setUp(self):
+ from scrapy.robotstxt import RerpRobotParser
+ super(RerpRobotParserTest, self)._setUp(RerpRobotParser)
+
+ def test_length_based_precedence(self):
+ raise unittest.SkipTest("Rerp does not support length based directives precedence.")
+
+
+class ProtegoRobotParserTest(BaseRobotParserTest, unittest.TestCase):
+ if not protego_available():
+ skip = "Protego parser is not installed"
+
+ def setUp(self):
+ from scrapy.robotstxt import ProtegoRobotParser
+ super(ProtegoRobotParserTest, self)._setUp(ProtegoRobotParser)
+
+ def test_order_based_precedence(self):
+ raise unittest.SkipTest("Protego does not support order based directives precedence.")
diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py
new file mode 100644
index 000000000..e0e3600e5
--- /dev/null
+++ b/tests/test_scheduler.py
@@ -0,0 +1,342 @@
+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.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(object):
+ def __init__(self):
+ self.slots = dict()
+
+ 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):
+
+ 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)
+ self.engine = MockEngine(downloader=MockDownloader())
+
+
+class SchedulerHandler(object):
+ 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()
+ self.mock_crawler.engine.downloader.close()
+
+ 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 = 'scrapy.pqueues.ScrapyPriorityQueue'
+
+
+class TestSchedulerOnDisk(BaseSchedulerOnDiskTester, unittest.TestCase):
+ priority_queue_cls = 'scrapy.pqueues.ScrapyPriorityQueue'
+
+
+_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):
+
+ def setUp(self):
+ self.tmpdir = tempfile.mkdtemp()
+
+ def tearDown(self):
+ shutil.rmtree(self.tmpdir)
+
+ def _migration(self, tmp_dir):
+ prev_scheduler_handler = SchedulerHandler()
+ prev_scheduler_handler.priority_queue_cls = 'scrapy.pqueues.ScrapyPriorityQueue'
+ 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.DownloaderAwarePriorityQueue'
+ next_scheduler_handler.jobdir = tmp_dir
+
+ next_scheduler_handler.create_scheduler()
+
+ def test_migration(self):
+ with self.assertRaises(ValueError):
+ self._migration(self.tmpdir)
+
+
+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 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)
+
+ 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)
+
+ 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)
+
+
+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
+
+
+class TestIntegrationWithDownloaderAwareInMemory(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)
+ start_urls = [url] * 6
+ yield self.crawler.crawl(start_urls)
+ self.assertEqual(self.crawler.stats.get_value('downloader/response_count'),
+ len(start_urls))
+
+
+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()
diff --git a/tests/test_selector.py b/tests/test_selector.py
index 526660cc8..09c2546fb 100644
--- a/tests/test_selector.py
+++ b/tests/test_selector.py
@@ -1,10 +1,9 @@
-import warnings
import weakref
+
from twisted.trial import unittest
+
from scrapy.http import TextResponse, HtmlResponse, XmlResponse
from scrapy.selector import Selector
-from scrapy.selector.lxmlsel import XmlXPathSelector, HtmlXPathSelector, XPathSelector
-from lxml import etree
class SelectorTestCase(unittest.TestCase):
@@ -20,17 +19,17 @@ class SelectorTestCase(unittest.TestCase):
for x in xl:
assert isinstance(x, Selector)
- self.assertEqual(sel.xpath('//input').extract(),
- [x.extract() for x in sel.xpath('//input')])
+ self.assertEqual(sel.xpath('//input').getall(),
+ [x.get() for x in sel.xpath('//input')])
- self.assertEqual([x.extract() for x in sel.xpath("//input[@name='a']/@name")],
+ self.assertEqual([x.get() for x in sel.xpath("//input[@name='a']/@name")],
[u'a'])
- self.assertEqual([x.extract() for x in sel.xpath("number(concat(//input[@name='a']/@value, //input[@name='b']/@value))")],
+ self.assertEqual([x.get() for x in sel.xpath("number(concat(//input[@name='a']/@value, //input[@name='b']/@value))")],
[u'12.0'])
- self.assertEqual(sel.xpath("concat('xpath', 'rules')").extract(),
+ self.assertEqual(sel.xpath("concat('xpath', 'rules')").getall(),
[u'xpathrules'])
- self.assertEqual([x.extract() for x in sel.xpath("concat(//input[@name='a']/@value, //input[@name='b']/@value)")],
+ self.assertEqual([x.get() for x in sel.xpath("concat(//input[@name='a']/@value, //input[@name='b']/@value)")],
[u'12'])
def test_root_base_url(self):
@@ -40,32 +39,16 @@ class SelectorTestCase(unittest.TestCase):
sel = Selector(response)
self.assertEqual(url, sel.root.base)
- def test_deprecated_root_argument(self):
- with warnings.catch_warnings(record=True) as w:
- root = etree.fromstring(u' ')
- sel = Selector(_root=root)
- self.assertIs(root, sel.root)
- self.assertEqual(str(w[-1].message),
- 'Argument `_root` is deprecated, use `root` instead')
-
- def test_deprecated_root_argument_ambiguous(self):
- with warnings.catch_warnings(record=True) as w:
- _root = etree.fromstring(u' ')
- root = etree.fromstring(u' ')
- sel = Selector(_root=_root, root=root)
- self.assertIs(root, sel.root)
- self.assertIn('Ignoring deprecated `_root` argument', str(w[-1].message))
-
def test_flavor_detection(self):
text = b'Hello
'
sel = Selector(XmlResponse('http://example.com', body=text, encoding='utf-8'))
self.assertEqual(sel.type, 'xml')
- self.assertEqual(sel.xpath("//div").extract(),
+ self.assertEqual(sel.xpath("//div").getall(),
[u'Hello
'])
sel = Selector(HtmlResponse('http://example.com', body=text, encoding='utf-8'))
self.assertEqual(sel.type, 'html')
- self.assertEqual(sel.xpath("//div").extract(),
+ self.assertEqual(sel.xpath("//div").getall(),
[u'Hello
'])
def test_http_header_encoding_precedence(self):
@@ -84,15 +67,15 @@ class SelectorTestCase(unittest.TestCase):
headers = {'Content-Type': ['text/html; charset=utf-8']}
response = HtmlResponse(url="http://example.com", headers=headers, body=html_utf8)
x = Selector(response)
- self.assertEqual(x.xpath("//span[@id='blank']/text()").extract(),
+ self.assertEqual(x.xpath("//span[@id='blank']/text()").getall(),
[u'\xa3'])
def test_badly_encoded_body(self):
# \xe9 alone isn't valid utf8 sequence
- r1 = TextResponse('http://www.example.com', \
- body=b'an Jos\xe9 de
', \
+ r1 = TextResponse('http://www.example.com',
+ body=b'an Jos\xe9 de
',
encoding='utf-8')
- Selector(r1).xpath('//text()').extract()
+ Selector(r1).xpath('//text()').getall()
def test_weakref_slots(self):
"""Check that classes are using slots and are weak-referenceable"""
@@ -101,111 +84,6 @@ class SelectorTestCase(unittest.TestCase):
assert not hasattr(x, '__dict__'), "%s does not use __slots__" % \
x.__class__.__name__
- def test_deprecated_selector_methods(self):
- sel = Selector(TextResponse(url="http://example.com", body=b'some text
'))
-
- with warnings.catch_warnings(record=True) as w:
- sel.select('//p')
- self.assertSubstring('Use .xpath() instead', str(w[-1].message))
-
- with warnings.catch_warnings(record=True) as w:
- sel.extract_unquoted()
- self.assertSubstring('Use .extract() instead', str(w[-1].message))
-
- def test_deprecated_selectorlist_methods(self):
- sel = Selector(TextResponse(url="http://example.com", body=b'some text
'))
-
- with warnings.catch_warnings(record=True) as w:
- sel.xpath('//p').select('.')
- self.assertSubstring('Use .xpath() instead', str(w[-1].message))
-
- with warnings.catch_warnings(record=True) as w:
- sel.xpath('//p').extract_unquoted()
- self.assertSubstring('Use .extract() instead', str(w[-1].message))
-
def test_selector_bad_args(self):
- with self.assertRaisesRegexp(ValueError, 'received both response and text'):
+ with self.assertRaisesRegex(ValueError, 'received both response and text'):
Selector(TextResponse(url='http://example.com', body=b''), text=u'')
-
-
-class DeprecatedXpathSelectorTest(unittest.TestCase):
-
- text = 'Hello
'
-
- def test_warnings_xpathselector(self):
- cls = XPathSelector
- with warnings.catch_warnings(record=True) as w:
- class UserClass(cls):
- pass
-
- # subclassing must issue a warning
- self.assertEqual(len(w), 1, str(cls))
- self.assertIn('scrapy.Selector', str(w[0].message))
-
- # subclass instance doesn't issue a warning
- usel = UserClass(text=self.text)
- self.assertEqual(len(w), 1)
-
- # class instance must issue a warning
- sel = cls(text=self.text)
- self.assertEqual(len(w), 2, str((cls, [x.message for x in w])))
- self.assertIn('scrapy.Selector', str(w[1].message))
-
- # subclass and instance checks
- self.assertTrue(issubclass(cls, Selector))
- self.assertTrue(isinstance(sel, Selector))
- self.assertTrue(isinstance(usel, Selector))
-
- def test_warnings_xmlxpathselector(self):
- cls = XmlXPathSelector
- with warnings.catch_warnings(record=True) as w:
- class UserClass(cls):
- pass
-
- # subclassing must issue a warning
- self.assertEqual(len(w), 1, str(cls))
- self.assertIn('scrapy.Selector', str(w[0].message))
-
- # subclass instance doesn't issue a warning
- usel = UserClass(text=self.text)
- self.assertEqual(len(w), 1)
-
- # class instance must issue a warning
- sel = cls(text=self.text)
- self.assertEqual(len(w), 2, str((cls, [x.message for x in w])))
- self.assertIn('scrapy.Selector', str(w[1].message))
-
- # subclass and instance checks
- self.assertTrue(issubclass(cls, Selector))
- self.assertTrue(issubclass(cls, XPathSelector))
- self.assertTrue(isinstance(sel, Selector))
- self.assertTrue(isinstance(usel, Selector))
- self.assertTrue(isinstance(sel, XPathSelector))
- self.assertTrue(isinstance(usel, XPathSelector))
-
- def test_warnings_htmlxpathselector(self):
- cls = HtmlXPathSelector
- with warnings.catch_warnings(record=True) as w:
- class UserClass(cls):
- pass
-
- # subclassing must issue a warning
- self.assertEqual(len(w), 1, str(cls))
- self.assertIn('scrapy.Selector', str(w[0].message))
-
- # subclass instance doesn't issue a warning
- usel = UserClass(text=self.text)
- self.assertEqual(len(w), 1)
-
- # class instance must issue a warning
- sel = cls(text=self.text)
- self.assertEqual(len(w), 2, str((cls, [x.message for x in w])))
- self.assertIn('scrapy.Selector', str(w[1].message))
-
- # subclass and instance checks
- self.assertTrue(issubclass(cls, Selector))
- self.assertTrue(issubclass(cls, XPathSelector))
- self.assertTrue(isinstance(sel, Selector))
- self.assertTrue(isinstance(usel, Selector))
- self.assertTrue(isinstance(sel, XPathSelector))
- self.assertTrue(isinstance(usel, XPathSelector))
diff --git a/tests/test_selector_csstranslator.py b/tests/test_selector_csstranslator.py
deleted file mode 100644
index 2d82fcba7..000000000
--- a/tests/test_selector_csstranslator.py
+++ /dev/null
@@ -1,22 +0,0 @@
-"""
-Selector tests for cssselect backend
-"""
-import warnings
-from twisted.trial import unittest
-from scrapy.selector.csstranslator import (
- ScrapyHTMLTranslator,
- ScrapyGenericTranslator,
- ScrapyXPathExpr
-)
-
-
-class DeprecatedClassesTest(unittest.TestCase):
-
- def test_deprecated_warnings(self):
- for cls in [ScrapyHTMLTranslator, ScrapyGenericTranslator, ScrapyXPathExpr]:
- with warnings.catch_warnings(record=True) as w:
- obj = cls()
- self.assertIn('%s is deprecated' % cls.__name__, str(w[-1].message),
- 'Missing deprecate warning for %s' % cls.__name__)
-
-
diff --git a/tests/test_settings/__init__.py b/tests/test_settings/__init__.py
index 863684075..fda44653a 100644
--- a/tests/test_settings/__init__.py
+++ b/tests/test_settings/__init__.py
@@ -1,18 +1,15 @@
-import six
import unittest
-import warnings
+from unittest import mock
from scrapy.settings import (BaseSettings, Settings, SettingsAttribute,
- CrawlerSettings, SETTINGS_PRIORITIES,
- get_settings_priority)
-from tests import mock
+ SETTINGS_PRIORITIES, get_settings_priority)
from . import default_settings
class SettingsGlobalFuncsTest(unittest.TestCase):
def test_get_settings_priority(self):
- for prio_str, prio_num in six.iteritems(SETTINGS_PRIORITIES):
+ for prio_str, prio_num in SETTINGS_PRIORITIES.items():
self.assertEqual(get_settings_priority(prio_str), prio_num)
self.assertEqual(get_settings_priority(99), 99)
@@ -45,14 +42,14 @@ class SettingsAttributeTest(unittest.TestCase):
new_dict = {'three': 11, 'four': 21}
attribute.set(new_dict, 10)
self.assertIsInstance(attribute.value, BaseSettings)
- six.assertCountEqual(self, attribute.value, new_dict)
- six.assertCountEqual(self, original_settings, original_dict)
+ self.assertCountEqual(attribute.value, new_dict)
+ self.assertCountEqual(original_settings, original_dict)
new_settings = BaseSettings({'five': 12}, 0)
attribute.set(new_settings, 0) # Insufficient priority
- six.assertCountEqual(self, attribute.value, new_dict)
+ self.assertCountEqual(attribute.value, new_dict)
attribute.set(new_settings, 10)
- six.assertCountEqual(self, attribute.value, new_settings)
+ self.assertCountEqual(attribute.value, new_settings)
def test_repr(self):
self.assertEqual(repr(self.attribute),
@@ -61,9 +58,6 @@ class SettingsAttributeTest(unittest.TestCase):
class BaseSettingsTest(unittest.TestCase):
- if six.PY3:
- assertItemsEqual = unittest.TestCase.assertCountEqual
-
def setUp(self):
self.settings = BaseSettings()
@@ -153,10 +147,10 @@ class BaseSettingsTest(unittest.TestCase):
self.settings.setmodule(
'tests.test_settings.default_settings', 10)
- self.assertItemsEqual(six.iterkeys(self.settings.attributes),
- six.iterkeys(ctrl_attributes))
+ self.assertCountEqual(self.settings.attributes.keys(),
+ ctrl_attributes.keys())
- for key in six.iterkeys(ctrl_attributes):
+ for key in ctrl_attributes.keys():
attr = self.settings.attributes[key]
ctrl_attr = ctrl_attributes[key]
self.assertEqual(attr.value, ctrl_attr.value)
@@ -232,7 +226,7 @@ class BaseSettingsTest(unittest.TestCase):
}
settings = self.settings
settings.attributes = {key: SettingsAttribute(value, 0) for key, value
- in six.iteritems(test_configuration)}
+ in test_configuration.items()}
self.assertTrue(settings.getbool('TEST_ENABLED1'))
self.assertTrue(settings.getbool('TEST_ENABLED2'))
@@ -281,9 +275,8 @@ class BaseSettingsTest(unittest.TestCase):
'TEST': BaseSettings({1: 10, 3: 30}, 'default'),
'HASNOBASE': BaseSettings({3: 3000}, 'default')})
s['TEST'].set(2, 200, 'cmdline')
- six.assertCountEqual(self, s.getwithbase('TEST'),
- {1: 1, 2: 200, 3: 30})
- six.assertCountEqual(self, s.getwithbase('HASNOBASE'), s['HASNOBASE'])
+ self.assertCountEqual(s.getwithbase('TEST'), {1: 1, 2: 200, 3: 30})
+ self.assertCountEqual(s.getwithbase('HASNOBASE'), s['HASNOBASE'])
self.assertEqual(s.getwithbase('NONEXISTENT'), {})
def test_maxpriority(self):
@@ -341,41 +334,9 @@ class BaseSettingsTest(unittest.TestCase):
self.assertTrue(frozencopy.frozen)
self.assertIsNot(frozencopy, self.settings)
- def test_deprecated_attribute_overrides(self):
- self.settings.set('BAR', 'fuz', priority='cmdline')
- with warnings.catch_warnings(record=True) as w:
- self.settings.overrides['BAR'] = 'foo'
- self.assertIn("Settings.overrides", str(w[0].message))
- self.assertEqual(self.settings.get('BAR'), 'foo')
- self.assertEqual(self.settings.overrides.get('BAR'), 'foo')
- self.assertIn('BAR', self.settings.overrides)
-
- self.settings.overrides.update(BAR='bus')
- self.assertEqual(self.settings.get('BAR'), 'bus')
- self.assertEqual(self.settings.overrides.get('BAR'), 'bus')
-
- self.settings.overrides.setdefault('BAR', 'fez')
- self.assertEqual(self.settings.get('BAR'), 'bus')
-
- self.settings.overrides.setdefault('FOO', 'fez')
- self.assertEqual(self.settings.get('FOO'), 'fez')
- self.assertEqual(self.settings.overrides.get('FOO'), 'fez')
-
- def test_deprecated_attribute_defaults(self):
- self.settings.set('BAR', 'fuz', priority='default')
- with warnings.catch_warnings(record=True) as w:
- self.settings.defaults['BAR'] = 'foo'
- self.assertIn("Settings.defaults", str(w[0].message))
- self.assertEqual(self.settings.get('BAR'), 'foo')
- self.assertEqual(self.settings.defaults.get('BAR'), 'foo')
- self.assertIn('BAR', self.settings.defaults)
-
class SettingsTest(unittest.TestCase):
- if six.PY3:
- assertItemsEqual = unittest.TestCase.assertCountEqual
-
def setUp(self):
self.settings = Settings()
@@ -422,33 +383,5 @@ class SettingsTest(unittest.TestCase):
self.assertEqual(mydict['key'], 'val')
-class CrawlerSettingsTest(unittest.TestCase):
-
- def test_deprecated_crawlersettings(self):
- def _get_settings(settings_dict=None):
- settings_module = type('SettingsModuleMock', (object,), settings_dict or {})
- return CrawlerSettings(settings_module)
-
- with warnings.catch_warnings(record=True) as w:
- settings = _get_settings()
- self.assertIn("CrawlerSettings is deprecated", str(w[0].message))
-
- # test_global_defaults
- self.assertEqual(settings.getint('DOWNLOAD_TIMEOUT'), 180)
-
- # test_defaults
- settings.defaults['DOWNLOAD_TIMEOUT'] = '99'
- self.assertEqual(settings.getint('DOWNLOAD_TIMEOUT'), 99)
-
- # test_settings_module
- settings = _get_settings({'DOWNLOAD_TIMEOUT': '3'})
- self.assertEqual(settings.getint('DOWNLOAD_TIMEOUT'), 3)
-
- # test_overrides
- settings = _get_settings({'DOWNLOAD_TIMEOUT': '3'})
- settings.overrides['DOWNLOAD_TIMEOUT'] = '15'
- self.assertEqual(settings.getint('DOWNLOAD_TIMEOUT'), 15)
-
-
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_settings/default_settings.py b/tests/test_settings/default_settings.py
index c24b5a9b9..26a555275 100644
--- a/tests/test_settings/default_settings.py
+++ b/tests/test_settings/default_settings.py
@@ -2,4 +2,3 @@
TEST_DEFAULT = 'defvalue'
TEST_DICT = {'key': 'val'}
-
diff --git a/tests/test_spider.py b/tests/test_spider.py
index 929e0fea8..317a27076 100644
--- a/tests/test_spider.py
+++ b/tests/test_spider.py
@@ -1,5 +1,6 @@
import gzip
import inspect
+from unittest import mock
import warnings
from io import BytesIO
@@ -10,15 +11,12 @@ from scrapy import signals
from scrapy.settings import Settings
from scrapy.http import Request, Response, TextResponse, XmlResponse, HtmlResponse
from scrapy.spiders.init import InitSpider
-from scrapy.spiders import Spider, BaseSpider, CrawlSpider, Rule, XMLFeedSpider, \
+from scrapy.spiders import Spider, CrawlSpider, Rule, XMLFeedSpider, \
CSVFeedSpider, SitemapSpider
from scrapy.linkextractors import LinkExtractor
from scrapy.exceptions import ScrapyDeprecationWarning
-from scrapy.utils.trackref import object_ref
from scrapy.utils.test import get_crawler
-from tests import mock
-
class SpiderTest(unittest.TestCase):
@@ -42,26 +40,15 @@ class SpiderTest(unittest.TestCase):
self.assertEqual(list(start_requests), [])
def test_spider_args(self):
- """Constructor arguments are assigned to spider attributes"""
+ """``__init__`` method arguments are assigned to spider attributes"""
spider = self.spider_class('example.com', foo='bar')
self.assertEqual(spider.foo, 'bar')
def test_spider_without_name(self):
- """Constructor arguments are assigned to spider attributes"""
+ """``__init__`` method arguments are assigned to spider attributes"""
self.assertRaises(ValueError, self.spider_class)
self.assertRaises(ValueError, self.spider_class, somearg='foo')
- def test_deprecated_set_crawler_method(self):
- spider = self.spider_class('example.com')
- crawler = get_crawler()
- with warnings.catch_warnings(record=True) as w:
- spider.set_crawler(crawler)
- self.assertIn("set_crawler", str(w[0].message))
- self.assertTrue(hasattr(spider, 'crawler'))
- self.assertIs(spider.crawler, crawler)
- self.assertTrue(hasattr(spider, 'settings'))
- self.assertIs(spider.settings, crawler.settings)
-
def test_from_crawler_crawler_and_settings_population(self):
crawler = get_crawler()
spider = self.spider_class.from_crawler(crawler, 'example.com')
@@ -105,11 +92,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)
@@ -147,10 +134,10 @@ class XMLFeedSpiderTest(SpiderTest):
def parse_node(self, response, selector):
yield {
- 'loc': selector.xpath('a:loc/text()').extract(),
- 'updated': selector.xpath('b:updated/text()').extract(),
- 'other': selector.xpath('other/@value').extract(),
- 'custom': selector.xpath('other/@b:custom').extract(),
+ 'loc': selector.xpath('a:loc/text()').getall(),
+ 'updated': selector.xpath('b:updated/text()').getall(),
+ 'other': selector.xpath('other/@value').getall(),
+ 'custom': selector.xpath('other/@b:custom').getall(),
}
for iterator in ('iternodes', 'xml'):
@@ -188,14 +175,33 @@ class CrawlSpiderTest(SpiderTest):
"""
spider_class = CrawlSpider
- def test_process_links(self):
+ def test_rule_without_link_extractor(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(),
+ )
+
+ 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_links(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_links="dummy_process_links"),
)
@@ -208,24 +214,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 +241,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 +264,113 @@ 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):
+
+ 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),
+ )
+
+ 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):
+
+ 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())
+
+ 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):
+
+ 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()
@@ -275,19 +384,13 @@ class CrawlSpiderTest(SpiderTest):
self.assertTrue(hasattr(spider, '_follow_links'))
self.assertFalse(spider._follow_links)
- def test_follow_links_attribute_deprecated_population(self):
- spider = self.spider_class('example.com')
- self.assertFalse(hasattr(spider, '_follow_links'))
+ def test_start_url(self):
+ spider = self.spider_class("example.com")
+ spider.start_url = 'https://www.example.com'
- spider.set_crawler(get_crawler())
- self.assertTrue(hasattr(spider, '_follow_links'))
- self.assertTrue(spider._follow_links)
-
- spider = self.spider_class('example.com')
- settings_dict = {'CRAWLSPIDER_FOLLOW_LINKS': False}
- spider.set_crawler(get_crawler(settings_dict=settings_dict))
- self.assertTrue(hasattr(spider, '_follow_links'))
- self.assertFalse(spider._follow_links)
+ with self.assertRaisesRegex(AttributeError,
+ r'^Crawling could not start.*$'):
+ list(spider.start_requests())
class SitemapSpiderTest(SpiderTest):
@@ -375,60 +478,110 @@ Sitemap: /sitemap-relative-url.xml
'http://www.example.com/schweiz-deutsch/',
'http://www.example.com/italiano/'])
+ def test_sitemap_filter(self):
+ sitemap = b"""
+
+
+ http://www.example.com/english/
+ 2010-01-01
+
+
+ http://www.example.com/portuguese/
+ 2005-01-01
+
+ """
+
+ class FilteredSitemapSpider(self.spider_class):
+ def sitemap_filter(self, entries):
+ from datetime import datetime
+ for entry in entries:
+ date_time = datetime.strptime(entry['lastmod'], '%Y-%m-%d')
+ if date_time.year > 2008:
+ yield entry
+
+ r = TextResponse(url="http://www.example.com/sitemap.xml", body=sitemap)
+ spider = self.spider_class("example.com")
+ self.assertEqual([req.url for req in spider._parse_sitemap(r)],
+ ['http://www.example.com/english/',
+ 'http://www.example.com/portuguese/'])
+
+ spider = FilteredSitemapSpider("example.com")
+ self.assertEqual([req.url for req in spider._parse_sitemap(r)],
+ ['http://www.example.com/english/'])
+
+ def test_sitemap_filter_with_alternate_links(self):
+ sitemap = b"""
+
+
+ http://www.example.com/english/article_1/
+ 2010-01-01
+
+
+
+ http://www.example.com/english/article_2/
+ 2015-01-01
+
+ """
+
+ class FilteredSitemapSpider(self.spider_class):
+ def sitemap_filter(self, entries):
+ for entry in entries:
+ alternate_links = entry.get('alternate', tuple())
+ for link in alternate_links:
+ if '/deutsch/' in link:
+ entry['loc'] = link
+ yield entry
+
+ r = TextResponse(url="http://www.example.com/sitemap.xml", body=sitemap)
+ spider = self.spider_class("example.com")
+ self.assertEqual([req.url for req in spider._parse_sitemap(r)],
+ ['http://www.example.com/english/article_1/',
+ 'http://www.example.com/english/article_2/'])
+
+ spider = FilteredSitemapSpider("example.com")
+ self.assertEqual([req.url for req in spider._parse_sitemap(r)],
+ ['http://www.example.com/deutsch/article_1/'])
+
+ def test_sitemapindex_filter(self):
+ sitemap = b"""
+
+
+ http://www.example.com/sitemap1.xml
+ 2004-01-01T20:00:00+00:00
+
+
+ http://www.example.com/sitemap2.xml
+ 2005-01-01
+
+ """
+
+ class FilteredSitemapSpider(self.spider_class):
+ def sitemap_filter(self, entries):
+ from datetime import datetime
+ for entry in entries:
+ date_time = datetime.strptime(entry['lastmod'].split('T')[0], '%Y-%m-%d')
+ if date_time.year > 2004:
+ yield entry
+
+ r = TextResponse(url="http://www.example.com/sitemap.xml", body=sitemap)
+ spider = self.spider_class("example.com")
+ self.assertEqual([req.url for req in spider._parse_sitemap(r)],
+ ['http://www.example.com/sitemap1.xml',
+ 'http://www.example.com/sitemap2.xml'])
+
+ spider = FilteredSitemapSpider("example.com")
+ self.assertEqual([req.url for req in spider._parse_sitemap(r)],
+ ['http://www.example.com/sitemap2.xml'])
+
class DeprecationTest(unittest.TestCase):
- def test_basespider_is_deprecated(self):
- with warnings.catch_warnings(record=True) as w:
-
- class MySpider1(BaseSpider):
- pass
-
- self.assertEqual(len(w), 1)
- self.assertEqual(w[0].category, ScrapyDeprecationWarning)
- self.assertEqual(w[0].lineno, inspect.getsourcelines(MySpider1)[1])
-
- def test_basespider_issubclass(self):
- class MySpider2(Spider):
- pass
-
- class MySpider2a(MySpider2):
- pass
-
- class Foo(object):
- pass
-
- class Foo2(object_ref):
- pass
-
- assert issubclass(MySpider2, BaseSpider)
- assert issubclass(MySpider2a, BaseSpider)
- assert not issubclass(Foo, BaseSpider)
- assert not issubclass(Foo2, BaseSpider)
-
- def test_basespider_isinstance(self):
- class MySpider3(Spider):
- name = 'myspider3'
-
- class MySpider3a(MySpider3):
- pass
-
- class Foo(object):
- pass
-
- class Foo2(object_ref):
- pass
-
- assert isinstance(MySpider3(), BaseSpider)
- assert isinstance(MySpider3a(), BaseSpider)
- assert not isinstance(Foo(), BaseSpider)
- assert not isinstance(Foo2(), BaseSpider)
-
def test_crawl_spider(self):
assert issubclass(CrawlSpider, Spider)
- assert issubclass(CrawlSpider, BaseSpider)
assert isinstance(CrawlSpider(name='foo'), Spider)
- assert isinstance(CrawlSpider(name='foo'), BaseSpider)
def test_make_requests_from_url_deprecated(self):
class MySpider4(Spider):
@@ -468,5 +621,5 @@ class NoParseMethodSpiderTest(unittest.TestCase):
resp = TextResponse(url="http://www.example.com/random_url", body=text)
exc_msg = 'Spider.parse callback is not defined'
- with self.assertRaisesRegexp(NotImplementedError, exc_msg):
+ with self.assertRaisesRegex(NotImplementedError, exc_msg):
spider.parse(resp)
diff --git a/tests/test_spiderloader/__init__.py b/tests/test_spiderloader/__init__.py
index 1cd59b99a..d8be6e277 100644
--- a/tests/test_spiderloader/__init__.py
+++ b/tests/test_spiderloader/__init__.py
@@ -84,8 +84,8 @@ class SpiderLoaderTest(unittest.TestCase):
module = 'tests.test_spiderloader.test_spiders.spider1'
runner = CrawlerRunner({'SPIDER_MODULES': [module]})
- self.assertRaisesRegexp(KeyError, 'Spider not found',
- runner.create_crawler, 'spider2')
+ self.assertRaisesRegex(KeyError, 'Spider not found',
+ runner.create_crawler, 'spider2')
crawler = runner.create_crawler('spider1')
self.assertTrue(issubclass(crawler.spidercls, scrapy.Spider))
@@ -109,6 +109,7 @@ class SpiderLoaderTest(unittest.TestCase):
spiders = spider_loader.list()
self.assertEqual(spiders, [])
+
class DuplicateSpiderNameLoaderTest(unittest.TestCase):
def setUp(self):
diff --git a/tests/test_spiderloader/test_spiders/nested/spider4.py b/tests/test_spiderloader/test_spiders/nested/spider4.py
index 35b71870a..dbd1fb123 100644
--- a/tests/test_spiderloader/test_spiders/nested/spider4.py
+++ b/tests/test_spiderloader/test_spiders/nested/spider4.py
@@ -1,5 +1,6 @@
from scrapy.spiders import Spider
+
class Spider4(Spider):
name = "spider4"
allowed_domains = ['spider4.com']
diff --git a/tests/test_spiderloader/test_spiders/spider0.py b/tests/test_spiderloader/test_spiders/spider0.py
index 75a90794e..af679dbd6 100644
--- a/tests/test_spiderloader/test_spiders/spider0.py
+++ b/tests/test_spiderloader/test_spiders/spider0.py
@@ -1,4 +1,5 @@
from scrapy.spiders import Spider
+
class Spider0(Spider):
allowed_domains = ["scrapy1.org", "scrapy3.org"]
diff --git a/tests/test_spiderloader/test_spiders/spider1.py b/tests/test_spiderloader/test_spiders/spider1.py
index 76efddc7f..6b4317a90 100644
--- a/tests/test_spiderloader/test_spiders/spider1.py
+++ b/tests/test_spiderloader/test_spiders/spider1.py
@@ -1,5 +1,6 @@
from scrapy.spiders import Spider
+
class Spider1(Spider):
name = "spider1"
allowed_domains = ["scrapy1.org", "scrapy3.org"]
diff --git a/tests/test_spiderloader/test_spiders/spider2.py b/tests/test_spiderloader/test_spiders/spider2.py
index 0badd8437..352601863 100644
--- a/tests/test_spiderloader/test_spiders/spider2.py
+++ b/tests/test_spiderloader/test_spiders/spider2.py
@@ -1,5 +1,6 @@
from scrapy.spiders import Spider
+
class Spider2(Spider):
name = "spider2"
allowed_domains = ["scrapy2.org", "scrapy3.org"]
diff --git a/tests/test_spiderloader/test_spiders/spider3.py b/tests/test_spiderloader/test_spiders/spider3.py
index d406f2d4f..84998ba35 100644
--- a/tests/test_spiderloader/test_spiders/spider3.py
+++ b/tests/test_spiderloader/test_spiders/spider3.py
@@ -1,5 +1,6 @@
from scrapy.spiders import Spider
+
class Spider3(Spider):
name = "spider3"
allowed_domains = ['spider3.com']
diff --git a/tests/test_spidermiddleware.py b/tests/test_spidermiddleware.py
new file mode 100644
index 000000000..55d665e79
--- /dev/null
+++ b/tests/test_spidermiddleware.py
@@ -0,0 +1,103 @@
+from unittest import mock
+
+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
+
+
+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)
+
+
+class ProcessSpiderExceptionReRaise(SpiderMiddlewareTestCase):
+ """Re raise the exception by returning None"""
+
+ def test_process_spider_exception_return_none(self):
+
+ class ProcessSpiderExceptionReturnNoneMiddleware:
+ 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(ProcessSpiderExceptionReturnNoneMiddleware())
+ self.mwman._add_middleware(RaiseExceptionProcessSpiderOutputMiddleware())
+ result = self._scrape_response()
+ self.assertIsInstance(result, Failure)
+ self.assertIsInstance(result.value, ZeroDivisionError)
diff --git a/tests/test_spidermiddleware_depth.py b/tests/test_spidermiddleware_depth.py
index 3685d5a6f..71cca2472 100644
--- a/tests/test_spidermiddleware_depth.py
+++ b/tests/test_spidermiddleware_depth.py
@@ -40,4 +40,3 @@ class TestDepthMiddleware(TestCase):
def tearDown(self):
self.stats.close_spider(self.spider, '')
-
diff --git a/tests/test_spidermiddleware_httperror.py b/tests/test_spidermiddleware_httperror.py
index 19e6bbdcd..dacd0147f 100644
--- a/tests/test_spidermiddleware_httperror.py
+++ b/tests/test_spidermiddleware_httperror.py
@@ -11,20 +11,21 @@ from scrapy.http import Response, Request
from scrapy.spiders import Spider
from scrapy.spidermiddlewares.httperror import HttpErrorMiddleware, HttpError
from scrapy.settings import Settings
+from tests.spiders import MockServerSpider
-class _HttpErrorSpider(Spider):
+class _HttpErrorSpider(MockServerSpider):
name = 'httperror'
- start_urls = [
- "http://localhost:8998/status?n=200",
- "http://localhost:8998/status?n=404",
- "http://localhost:8998/status?n=402",
- "http://localhost:8998/status?n=500",
- ]
bypass_status_codes = set()
def __init__(self, *args, **kwargs):
super(_HttpErrorSpider, self).__init__(*args, **kwargs)
+ self.start_urls = [
+ self.mockserver.url("/status?n=200"),
+ self.mockserver.url("/status?n=404"),
+ self.mockserver.url("/status?n=402"),
+ self.mockserver.url("/status?n=500"),
+ ]
self.failed = set()
self.skipped = set()
self.parsed = set()
@@ -169,7 +170,7 @@ class TestHttpErrorMiddlewareIntegrational(TrialTestCase):
@defer.inlineCallbacks
def test_middleware_works(self):
crawler = get_crawler(_HttpErrorSpider)
- yield crawler.crawl()
+ yield crawler.crawl(mockserver=self.mockserver)
assert not crawler.spider.skipped, crawler.spider.skipped
self.assertEqual(crawler.spider.parsed, {'200'})
self.assertEqual(crawler.spider.failed, {'404', '402', '500'})
@@ -184,7 +185,7 @@ class TestHttpErrorMiddlewareIntegrational(TrialTestCase):
def test_logging(self):
crawler = get_crawler(_HttpErrorSpider)
with LogCapture() as log:
- yield crawler.crawl(bypass_status_codes={402})
+ yield crawler.crawl(mockserver=self.mockserver, bypass_status_codes={402})
self.assertEqual(crawler.spider.parsed, {'200', '402'})
self.assertEqual(crawler.spider.skipped, {'402'})
self.assertEqual(crawler.spider.failed, {'404', '500'})
@@ -199,7 +200,7 @@ class TestHttpErrorMiddlewareIntegrational(TrialTestCase):
# HttpError logs ignored responses with level INFO
crawler = get_crawler(_HttpErrorSpider)
with LogCapture(level=logging.INFO) as log:
- yield crawler.crawl()
+ yield crawler.crawl(mockserver=self.mockserver)
self.assertEqual(crawler.spider.parsed, {'200'})
self.assertEqual(crawler.spider.failed, {'404', '402', '500'})
@@ -211,7 +212,7 @@ class TestHttpErrorMiddlewareIntegrational(TrialTestCase):
# with level WARNING, we shouldn't capture anything from HttpError
crawler = get_crawler(_HttpErrorSpider)
with LogCapture(level=logging.WARNING) as log:
- yield crawler.crawl()
+ yield crawler.crawl(mockserver=self.mockserver)
self.assertEqual(crawler.spider.parsed, {'200'})
self.assertEqual(crawler.spider.failed, {'404', '402', '500'})
diff --git a/tests/test_spidermiddleware_offsite.py b/tests/test_spidermiddleware_offsite.py
index 7e4af0d4c..7511aa568 100644
--- a/tests/test_spidermiddleware_offsite.py
+++ b/tests/test_spidermiddleware_offsite.py
@@ -1,13 +1,12 @@
from unittest import TestCase
-
-from six.moves.urllib.parse import urlparse
+from urllib.parse import urlparse
+import warnings
from scrapy.http import Response, Request
from scrapy.spiders import Spider
-from scrapy.spidermiddlewares.offsite import OffsiteMiddleware
-from scrapy.spidermiddlewares.offsite import URLWarning
+from scrapy.spidermiddlewares.offsite import OffsiteMiddleware, URLWarning
from scrapy.utils.test import get_crawler
-import warnings
+
class TestOffsiteMiddleware(TestCase):
@@ -53,6 +52,7 @@ class TestOffsiteMiddleware2(TestOffsiteMiddleware):
out = list(self.mw.process_spider_output(res, reqs, self.spider))
self.assertEqual(out, reqs)
+
class TestOffsiteMiddleware3(TestOffsiteMiddleware2):
def _get_spider(self):
@@ -73,7 +73,7 @@ class TestOffsiteMiddleware4(TestOffsiteMiddleware3):
class TestOffsiteMiddleware5(TestOffsiteMiddleware4):
-
+
def test_get_host_regex(self):
self.spider.allowed_domains = ['http://scrapytest.org', 'scrapy.org', 'scrapy.test.org']
with warnings.catch_warnings(record=True) as w:
diff --git a/tests/test_spidermiddleware_output_chain.py b/tests/test_spidermiddleware_output_chain.py
new file mode 100644
index 000000000..739cf1c2d
--- /dev/null
+++ b/tests/test_spidermiddleware_output_chain.py
@@ -0,0 +1,383 @@
+
+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 LogExceptionMiddleware:
+ def process_spider_exception(self, response, exception, spider):
+ spider.logger.info('Middleware: %s exception caught', exception.__class__.__name__)
+ return None
+
+
+# ================================================================================
+# (0) recover from an exception on a spider callback
+class RecoverySpider(Spider):
+ name = 'RecoverySpider'
+ 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.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):
+ 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),
+ ]
+
+
+# ================================================================================
+# (1) exceptions from a spider middleware's process_spider_input method
+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
+ __name__ + '.LogExceptionMiddleware': 10,
+ __name__ + '.FailProcessSpiderInputMiddleware': 8,
+ __name__ + '.LogExceptionMiddleware': 6,
+ # engine
+ }
+ }
+
+ def start_requests(self):
+ 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'}
+
+
+# ================================================================================
+# (2) exceptions from a spider callback (generator)
+class GeneratorCallbackSpider(Spider):
+ name = 'GeneratorCallbackSpider'
+ 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}
+ raise ImportError()
+
+
+# ================================================================================
+# (3) exceptions from a spider callback (not a generator)
+class NotGeneratorCallbackSpider(Spider):
+ name = 'NotGeneratorCallbackSpider'
+ 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}]
+
+
+# ================================================================================
+# (4) exceptions from a middleware process_spider_output method (generator)
+class GeneratorOutputChainSpider(Spider):
+ name = 'GeneratorOutputChainSpider'
+ custom_settings = {
+ 'SPIDER_MIDDLEWARES': {
+ __name__ + '.GeneratorFailMiddleware': 10,
+ __name__ + '.GeneratorDoNothingAfterFailureMiddleware': 8,
+ __name__ + '.GeneratorRecoverMiddleware': 5,
+ __name__ + '.GeneratorDoNothingAfterRecoveryMiddleware': 3,
+ },
+ }
+
+ def start_requests(self):
+ yield Request(self.mockserver.url('/status?n=200'))
+
+ def parse(self, response):
+ yield {'processed': ['parse-first-item']}
+ yield {'processed': ['parse-second-item']}
+
+
+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__)
+ spider.logger.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__))
+ yield r
+ raise LookupError()
+
+ def process_spider_exception(self, response, exception, spider):
+ method = '{}.process_spider_exception'.format(self.__class__.__name__)
+ spider.logger.info('%s: %s caught', method, exception.__class__.__name__)
+ yield {'processed': [method]}
+
+
+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__))
+ yield r
+
+ def process_spider_exception(self, response, exception, spider):
+ method = '{}.process_spider_exception'.format(self.__class__.__name__)
+ spider.logger.info('%s: %s caught', method, exception.__class__.__name__)
+ yield {'processed': [method]}
+
+
+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__)
+ spider.logger.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__)
+ spider.logger.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__)
+ spider.logger.info('%s: %s caught', method, exception.__class__.__name__)
+ return [{'processed': [method]}]
+
+
+class NotGeneratorDoNothingAfterRecoveryMiddleware(_NotGeneratorDoNothingMiddleware):
+ pass
+
+
+# ================================================================================
+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(mockserver=self.mockserver)
+ raise defer.returnValue(log)
+
+ @defer.inlineCallbacks
+ def test_recovery(self):
+ """
+ (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: 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
+ def test_process_spider_input_without_errback(self):
+ """
+ (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(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))
+ 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 callback (returning a generator) should
+ 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))
+ self.assertIn("'item_scraped_count': 2", str(log2))
+
+ @defer.inlineCallbacks
+ 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. No items should be processed.
+ """
+ log3 = yield self.crawl_log(NotGeneratorCallbackSpider)
+ 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 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("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))
+
+ @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))
diff --git a/tests/test_spidermiddleware_referer.py b/tests/test_spidermiddleware_referer.py
index 21439c20e..7cc17600c 100644
--- a/tests/test_spidermiddleware_referer.py
+++ b/tests/test_spidermiddleware_referer.py
@@ -1,8 +1,7 @@
-from six.moves.urllib.parse import urlparse
+from urllib.parse import urlparse
from unittest import TestCase
import warnings
-from scrapy.exceptions import NotConfigured
from scrapy.http import Response, Request
from scrapy.settings import Settings
from scrapy.spiders import Spider
@@ -349,6 +348,7 @@ class TestSettingsCustomPolicy(TestRefererMiddleware):
]
+
# --- Tests using Request meta dict to set policy
class TestRequestMetaDefault(MixinDefault, TestRefererMiddleware):
req_meta = {'referrer_policy': POLICY_SCRAPY_DEFAULT}
@@ -518,14 +518,17 @@ class TestPolicyHeaderPredecence001(MixinUnsafeUrl, TestRefererMiddleware):
settings = {'REFERRER_POLICY': 'scrapy.spidermiddlewares.referer.SameOriginPolicy'}
resp_headers = {'Referrer-Policy': POLICY_UNSAFE_URL.upper()}
+
class TestPolicyHeaderPredecence002(MixinNoReferrer, TestRefererMiddleware):
settings = {'REFERRER_POLICY': 'scrapy.spidermiddlewares.referer.NoReferrerWhenDowngradePolicy'}
resp_headers = {'Referrer-Policy': POLICY_NO_REFERRER.swapcase()}
+
class TestPolicyHeaderPredecence003(MixinNoReferrerWhenDowngrade, TestRefererMiddleware):
settings = {'REFERRER_POLICY': 'scrapy.spidermiddlewares.referer.OriginWhenCrossOriginPolicy'}
resp_headers = {'Referrer-Policy': POLICY_NO_REFERRER_WHEN_DOWNGRADE.title()}
+
class TestPolicyHeaderPredecence004(MixinNoReferrerWhenDowngrade, TestRefererMiddleware):
"""
The empty string means "no-referrer-when-downgrade"
@@ -545,8 +548,8 @@ class TestReferrerOnRedirect(TestRefererMiddleware):
(301, 'http://scrapytest.org/3'),
(301, 'http://scrapytest.org/4'),
),
- b'http://scrapytest.org/1', # expected initial referer
- b'http://scrapytest.org/1', # expected referer for the redirection request
+ b'http://scrapytest.org/1', # expected initial referer
+ b'http://scrapytest.org/1', # expected referer for the redirection request
),
( 'https://scrapytest.org/1',
'https://scrapytest.org/2',
@@ -606,8 +609,8 @@ class TestReferrerOnRedirectNoReferrer(TestReferrerOnRedirect):
(301, 'http://scrapytest.org/3'),
(301, 'http://scrapytest.org/4'),
),
- None, # expected initial "Referer"
- None, # expected "Referer" for the redirection request
+ None, # expected initial "Referer"
+ None, # expected "Referer" for the redirection request
),
( 'https://scrapytest.org/1',
'https://scrapytest.org/2',
@@ -645,8 +648,8 @@ class TestReferrerOnRedirectSameOrigin(TestReferrerOnRedirect):
(301, 'http://scrapytest.org/103'),
(301, 'http://scrapytest.org/104'),
),
- b'http://scrapytest.org/101', # expected initial "Referer"
- b'http://scrapytest.org/101', # expected referer for the redirection request
+ b'http://scrapytest.org/101', # expected initial "Referer"
+ b'http://scrapytest.org/101', # expected referer for the redirection request
),
( 'https://scrapytest.org/201',
'https://scrapytest.org/202',
@@ -754,8 +757,8 @@ class TestReferrerOnRedirectOriginWhenCrossOrigin(TestReferrerOnRedirect):
(301, 'http://scrapytest.org/103'),
(301, 'http://scrapytest.org/104'),
),
- b'http://scrapytest.org/101', # expected initial referer
- b'http://scrapytest.org/101', # expected referer for the redirection request
+ b'http://scrapytest.org/101', # expected initial referer
+ b'http://scrapytest.org/101', # expected referer for the redirection request
),
( 'https://scrapytest.org/201',
'https://scrapytest.org/202',
@@ -824,8 +827,8 @@ class TestReferrerOnRedirectStrictOriginWhenCrossOrigin(TestReferrerOnRedirect):
(301, 'http://scrapytest.org/103'),
(301, 'http://scrapytest.org/104'),
),
- b'http://scrapytest.org/101', # expected initial referer
- b'http://scrapytest.org/101', # expected referer for the redirection request
+ b'http://scrapytest.org/101', # expected initial referer
+ b'http://scrapytest.org/101', # expected referer for the redirection request
),
( 'https://scrapytest.org/201',
'https://scrapytest.org/202',
diff --git a/tests/test_spidermiddleware_urllength.py b/tests/test_spidermiddleware_urllength.py
index a0aae0fdd..5ef2b23fd 100644
--- a/tests/test_spidermiddleware_urllength.py
+++ b/tests/test_spidermiddleware_urllength.py
@@ -18,4 +18,3 @@ class TestUrlLengthMiddleware(TestCase):
spider = Spider('foo')
out = list(mw.process_spider_output(res, reqs, spider))
self.assertEqual(out, [short_url_req])
-
diff --git a/tests/test_squeues.py b/tests/test_squeues.py
index 3ded5c027..d5fcf2f7f 100644
--- a/tests/test_squeues.py
+++ b/tests/test_squeues.py
@@ -7,16 +7,20 @@ from scrapy.http import Request
from scrapy.loader import ItemLoader
from scrapy.selector import Selector
+
class TestItem(Item):
name = Field()
+
def _test_procesor(x):
return x + x
+
class TestLoader(ItemLoader):
default_item_class = TestItem
name_out = staticmethod(_test_procesor)
+
def nonserializable_object_test(self):
q = self.queue()
try:
@@ -35,6 +39,7 @@ def nonserializable_object_test(self):
sel = Selector(text='some text
')
self.assertRaises(ValueError, q.push, sel)
+
class MarshalFifoDiskQueueTest(t.FifoDiskQueueTest):
chunksize = 100000
@@ -53,15 +58,19 @@ class MarshalFifoDiskQueueTest(t.FifoDiskQueueTest):
test_nonserializable_object = nonserializable_object_test
+
class ChunkSize1MarshalFifoDiskQueueTest(MarshalFifoDiskQueueTest):
chunksize = 1
+
class ChunkSize2MarshalFifoDiskQueueTest(MarshalFifoDiskQueueTest):
chunksize = 2
+
class ChunkSize3MarshalFifoDiskQueueTest(MarshalFifoDiskQueueTest):
chunksize = 3
+
class ChunkSize4MarshalFifoDiskQueueTest(MarshalFifoDiskQueueTest):
chunksize = 4
@@ -100,15 +109,19 @@ class PickleFifoDiskQueueTest(MarshalFifoDiskQueueTest):
self.assertEqual(r.url, r2.url)
assert r2.meta['request'] is r2
+
class ChunkSize1PickleFifoDiskQueueTest(PickleFifoDiskQueueTest):
chunksize = 1
+
class ChunkSize2PickleFifoDiskQueueTest(PickleFifoDiskQueueTest):
chunksize = 2
+
class ChunkSize3PickleFifoDiskQueueTest(PickleFifoDiskQueueTest):
chunksize = 3
+
class ChunkSize4PickleFifoDiskQueueTest(PickleFifoDiskQueueTest):
chunksize = 4
diff --git a/tests/test_stats.py b/tests/test_stats.py
index 9f950ebc9..2bbbb9e2c 100644
--- a/tests/test_stats.py
+++ b/tests/test_stats.py
@@ -1,10 +1,55 @@
+from datetime import datetime
import unittest
+from unittest import mock
+from scrapy.extensions.corestats import CoreStats
from scrapy.spiders import Spider
from scrapy.statscollectors import StatsCollector, DummyStatsCollector
from scrapy.utils.test import get_crawler
+class CoreStatsExtensionTest(unittest.TestCase):
+
+ def setUp(self):
+ self.crawler = get_crawler(Spider)
+ self.spider = self.crawler._create_spider('foo')
+
+ @mock.patch('scrapy.extensions.corestats.datetime')
+ def test_core_stats_default_stats_collector(self, mock_datetime):
+ fixed_datetime = datetime(2019, 12, 1, 11, 38)
+ mock_datetime.utcnow = mock.Mock(return_value=fixed_datetime)
+ self.crawler.stats = StatsCollector(self.crawler)
+ ext = CoreStats.from_crawler(self.crawler)
+ ext.spider_opened(self.spider)
+ ext.item_scraped({}, self.spider)
+ ext.response_received(self.spider)
+ ext.item_dropped({}, self.spider, ZeroDivisionError())
+ ext.spider_closed(self.spider, 'finished')
+ self.assertEqual(
+ ext.stats._stats,
+ {
+ 'start_time': fixed_datetime,
+ 'finish_time': fixed_datetime,
+ 'item_scraped_count': 1,
+ 'response_received_count': 1,
+ 'item_dropped_count': 1,
+ 'item_dropped_reasons_count/ZeroDivisionError': 1,
+ 'finish_reason': 'finished',
+ 'elapsed_time_seconds': 0.0,
+ }
+ )
+
+ def test_core_stats_dummy_stats_collector(self):
+ self.crawler.stats = DummyStatsCollector(self.crawler)
+ ext = CoreStats.from_crawler(self.crawler)
+ ext.spider_opened(self.spider)
+ ext.item_scraped({}, self.spider)
+ ext.response_received(self.spider)
+ ext.item_dropped({}, self.spider, ZeroDivisionError())
+ ext.spider_closed(self.spider, 'finished')
+ self.assertEqual(ext.stats._stats, {})
+
+
class StatsCollectorTest(unittest.TestCase):
def setUp(self):
diff --git a/tests/test_toplevel.py b/tests/test_toplevel.py
index 91bbe43bc..fdc5df166 100644
--- a/tests/test_toplevel.py
+++ b/tests/test_toplevel.py
@@ -1,12 +1,12 @@
from unittest import TestCase
-import six
+
import scrapy
class ToplevelTestCase(TestCase):
def test_version(self):
- self.assertIs(type(scrapy.__version__), six.text_type)
+ self.assertIs(type(scrapy.__version__), str)
def test_version_info(self):
self.assertIs(type(scrapy.version_info), tuple)
diff --git a/tests/test_urlparse_monkeypatches.py b/tests/test_urlparse_monkeypatches.py
index 22e39821c..bea0cf3e5 100644
--- a/tests/test_urlparse_monkeypatches.py
+++ b/tests/test_urlparse_monkeypatches.py
@@ -1,4 +1,4 @@
-from six.moves.urllib.parse import urlparse
+from urllib.parse import urlparse
import unittest
diff --git a/tests/test_utils_asyncio.py b/tests/test_utils_asyncio.py
new file mode 100644
index 000000000..44acc24af
--- /dev/null
+++ b/tests/test_utils_asyncio.py
@@ -0,0 +1,17 @@
+from unittest import TestCase
+
+from pytest import mark
+
+from scrapy.utils.asyncio import is_asyncio_reactor_installed, install_asyncio_reactor
+
+
+@mark.usefixtures('reactor_pytest')
+class AsyncioTest(TestCase):
+
+ def test_is_asyncio_reactor_installed(self):
+ # the result should depend only on the pytest --reactor argument
+ self.assertEqual(is_asyncio_reactor_installed(), self.reactor_pytest == 'asyncio')
+
+ def test_install_asyncio_reactor(self):
+ # this should do nothing
+ install_asyncio_reactor()
diff --git a/tests/test_utils_conf.py b/tests/test_utils_conf.py
index f203c32ef..02d8ba51e 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,
@@ -79,7 +79,7 @@ class BuildComponentListTest(unittest.TestCase):
self.assertRaises(ValueError, build_component_list, {}, d, convert=lambda x: x)
d = {'one': {'a': 'a', 'b': 2}}
self.assertRaises(ValueError, build_component_list, {}, d, convert=lambda x: x)
- d = {'one': 'lorem ipsum',}
+ d = {'one': 'lorem ipsum'}
self.assertRaises(ValueError, build_component_list, {}, d, convert=lambda x: x)
diff --git a/tests/test_utils_console.py b/tests/test_utils_console.py
index 65782747b..380c41367 100644
--- a/tests/test_utils_console.py
+++ b/tests/test_utils_console.py
@@ -14,6 +14,7 @@ try:
except ImportError:
ipy = False
+
class UtilsConsoleTestCase(unittest.TestCase):
def test_get_shell_embed_func(self):
@@ -21,7 +22,7 @@ class UtilsConsoleTestCase(unittest.TestCase):
shell = get_shell_embed_func(['invalid'])
self.assertEqual(shell, None)
- shell = get_shell_embed_func(['invalid','python'])
+ shell = get_shell_embed_func(['invalid', 'python'])
self.assertTrue(callable(shell))
self.assertEqual(shell.__name__, '_embed_standard_shell')
diff --git a/tests/test_utils_curl.py b/tests/test_utils_curl.py
new file mode 100644
index 000000000..50e1bfd5f
--- /dev/null
+++ b/tests/test_utils_curl.py
@@ -0,0 +1,208 @@
+import unittest
+import warnings
+
+from w3lib.http import basic_auth_header
+
+from scrapy import Request
+from scrapy.utils.curl import curl_to_request_kwargs
+
+
+class CurlToRequestKwargsTest(unittest.TestCase):
+ maxDiff = 5000
+
+ def _test_command(self, curl_command, expected_result):
+ result = curl_to_request_kwargs(curl_command)
+ self.assertEqual(result, expected_result)
+ try:
+ Request(**result)
+ except TypeError as e:
+ self.fail("Request kwargs are not correct {}".format(e))
+
+ def test_get(self):
+ curl_command = "curl http://example.org/"
+ expected_result = {"method": "GET", "url": "http://example.org/"}
+ self._test_command(curl_command, expected_result)
+
+ def test_get_without_scheme(self):
+ curl_command = "curl www.example.org"
+ expected_result = {"method": "GET", "url": "http://www.example.org"}
+ self._test_command(curl_command, expected_result)
+
+ def test_get_basic_auth(self):
+ curl_command = 'curl "https://api.test.com/" -u ' \
+ '"some_username:some_password"'
+ expected_result = {
+ "method": "GET",
+ "url": "https://api.test.com/",
+ "headers": [
+ (
+ "Authorization",
+ basic_auth_header("some_username", "some_password")
+ )
+ ],
+ }
+ self._test_command(curl_command, expected_result)
+
+ def test_get_complex(self):
+ curl_command = (
+ "curl 'http://httpbin.org/get' -H 'Accept-Encoding: gzip, deflate'"
+ " -H 'Accept-Language: en-US,en;q=0.9,ru;q=0.8,es;q=0.7' -H 'Upgra"
+ "de-Insecure-Requests: 1' -H 'User-Agent: Mozilla/5.0 (X11; Linux "
+ "x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/62"
+ ".0.3202.75 Chrome/62.0.3202.75 Safari/537.36' -H 'Accept: text/ht"
+ "ml,application/xhtml+xml,application/xml;q=0.9,image/webp,image/a"
+ "png,*/*;q=0.8' -H 'Referer: http://httpbin.org/' -H 'Cookie: _gau"
+ "ges_unique_year=1; _gauges_unique=1; _gauges_unique_month=1; _gau"
+ "ges_unique_hour=1; _gauges_unique_day=1' -H 'Connection: keep-ali"
+ "ve' --compressed"
+ )
+ expected_result = {
+ "method": "GET",
+ "url": "http://httpbin.org/get",
+ "headers": [
+ ("Accept-Encoding", "gzip, deflate"),
+ ("Accept-Language", "en-US,en;q=0.9,ru;q=0.8,es;q=0.7"),
+ ("Upgrade-Insecure-Requests", "1"),
+ (
+ "User-Agent",
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML"
+ ", like Gecko) Ubuntu Chromium/62.0.3202.75 Chrome/62.0.32"
+ "02.75 Safari/537.36",
+ ),
+ (
+ "Accept",
+ "text/html,application/xhtml+xml,application/xml;q=0.9,ima"
+ "ge/webp,image/apng,*/*;q=0.8",
+ ),
+ ("Referer", "http://httpbin.org/"),
+ ("Connection", "keep-alive"),
+ ],
+ "cookies": {
+ '_gauges_unique_year': '1',
+ '_gauges_unique_hour': '1',
+ '_gauges_unique_day': '1',
+ '_gauges_unique': '1',
+ '_gauges_unique_month': '1'
+ },
+ }
+ self._test_command(curl_command, expected_result)
+
+ def test_post(self):
+ curl_command = (
+ "curl 'http://httpbin.org/post' -X POST -H 'Cookie: _gauges_unique"
+ "_year=1; _gauges_unique=1; _gauges_unique_month=1; _gauges_unique"
+ "_hour=1; _gauges_unique_day=1' -H 'Origin: http://httpbin.org' -H"
+ " 'Accept-Encoding: gzip, deflate' -H 'Accept-Language: en-US,en;q"
+ "=0.9,ru;q=0.8,es;q=0.7' -H 'Upgrade-Insecure-Requests: 1' -H 'Use"
+ "r-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTM"
+ "L, like Gecko) Ubuntu Chromium/62.0.3202.75 Chrome/62.0.3202.75 S"
+ "afari/537.36' -H 'Content-Type: application/x-www-form-urlencoded"
+ "' -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0"
+ ".9,image/webp,image/apng,*/*;q=0.8' -H 'Cache-Control: max-age=0'"
+ " -H 'Referer: http://httpbin.org/forms/post' -H 'Connection: keep"
+ "-alive' --data 'custname=John+Smith&custtel=500&custemail=jsmith%"
+ "40example.org&size=small&topping=cheese&topping=onion&delivery=12"
+ "%3A15&comments=' --compressed"
+ )
+ expected_result = {
+ "method": "POST",
+ "url": "http://httpbin.org/post",
+ "body": "custname=John+Smith&custtel=500&custemail=jsmith%40exampl"
+ "e.org&size=small&topping=cheese&topping=onion&delivery=12"
+ "%3A15&comments=",
+ "cookies": {
+ '_gauges_unique_year': '1',
+ '_gauges_unique_hour': '1',
+ '_gauges_unique_day': '1',
+ '_gauges_unique': '1',
+ '_gauges_unique_month': '1'
+ },
+ "headers": [
+ ("Origin", "http://httpbin.org"),
+ ("Accept-Encoding", "gzip, deflate"),
+ ("Accept-Language", "en-US,en;q=0.9,ru;q=0.8,es;q=0.7"),
+ ("Upgrade-Insecure-Requests", "1"),
+ (
+ "User-Agent",
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML"
+ ", like Gecko) Ubuntu Chromium/62.0.3202.75 Chrome/62.0.32"
+ "02.75 Safari/537.36",
+ ),
+ ("Content-Type", "application/x-www-form-urlencoded"),
+ (
+ "Accept",
+ "text/html,application/xhtml+xml,application/xml;q=0.9,ima"
+ "ge/webp,image/apng,*/*;q=0.8",
+ ),
+ ("Cache-Control", "max-age=0"),
+ ("Referer", "http://httpbin.org/forms/post"),
+ ("Connection", "keep-alive"),
+ ],
+ }
+ self._test_command(curl_command, expected_result)
+
+ def test_patch(self):
+ curl_command = (
+ 'curl "https://example.com/api/fake" -u "username:password" -H "Ac'
+ 'cept: application/vnd.go.cd.v4+json" -H "Content-Type: applicatio'
+ 'n/json" -X PATCH -d \'{"hostname": "agent02.example.com", "agent'
+ '_config_state": "Enabled", "resources": ["Java","Linux"], "enviro'
+ 'nments": ["Dev"]}\''
+ )
+ expected_result = {
+ "method": "PATCH",
+ "url": "https://example.com/api/fake",
+ "headers": [
+ ("Accept", "application/vnd.go.cd.v4+json"),
+ ("Content-Type", "application/json"),
+ ("Authorization", basic_auth_header("username", "password")),
+ ],
+ "body": '{"hostname": "agent02.example.com", "agent_config_state"'
+ ': "Enabled", "resources": ["Java","Linux"], "environments'
+ '": ["Dev"]}',
+ }
+ self._test_command(curl_command, expected_result)
+
+ def test_delete(self):
+ curl_command = 'curl -X "DELETE" https://www.url.com/page'
+ expected_result = {
+ "method": "DELETE", "url": "https://www.url.com/page"
+ }
+ self._test_command(curl_command, expected_result)
+
+ def test_get_silent(self):
+ curl_command = 'curl --silent "www.example.com"'
+ expected_result = {"method": "GET", "url": "http://www.example.com"}
+ self.assertEqual(curl_to_request_kwargs(curl_command), expected_result)
+
+ def test_too_few_arguments_error(self):
+ self.assertRaisesRegex(
+ ValueError,
+ r"too few arguments|the following arguments are required:\s*url",
+ lambda: curl_to_request_kwargs("curl"),
+ )
+
+ def test_ignore_unknown_options(self):
+ # case 1: ignore_unknown_options=True:
+ with warnings.catch_warnings(): # avoid warning when executing tests
+ warnings.simplefilter('ignore')
+ curl_command = 'curl --bar --baz http://www.example.com'
+ expected_result = \
+ {"method": "GET", "url": "http://www.example.com"}
+ self.assertEqual(curl_to_request_kwargs(curl_command), expected_result)
+
+ # case 2: ignore_unknown_options=False (raise exception):
+ self.assertRaisesRegex(
+ ValueError,
+ "Unrecognized options:.*--bar.*--baz",
+ lambda: curl_to_request_kwargs(
+ "curl --bar --baz http://www.example.com",
+ ignore_unknown_options=False
+ ),
+ )
+
+ def test_must_start_with_curl_error(self):
+ self.assertRaises(
+ ValueError,
+ lambda: curl_to_request_kwargs("carl -X POST http://example.org")
+ )
diff --git a/tests/test_utils_datatypes.py b/tests/test_utils_datatypes.py
index 5b83869b8..e5aa56eb9 100644
--- a/tests/test_utils_datatypes.py
+++ b/tests/test_utils_datatypes.py
@@ -1,11 +1,15 @@
import copy
import unittest
-from collections import Mapping, MutableMapping
+from collections.abc import Mapping, MutableMapping
+
+from scrapy.http import Request
+from scrapy.utils.datatypes import CaselessDict, LocalCache, LocalWeakReferencedCache, SequenceExclude
+from scrapy.utils.python import garbage_collect
-from scrapy.utils.datatypes import CaselessDict, SequenceExclude
__doctests__ = ['scrapy.utils.datatypes']
+
class CaselessDictTest(unittest.TestCase):
def test_init_dict(self):
@@ -190,14 +194,6 @@ class SequenceExcludeTest(unittest.TestCase):
self.assertIn(20, d)
self.assertNotIn(15, d)
- def test_six_range(self):
- import six.moves
- seq = six.moves.range(10**3, 10**6)
- d = SequenceExclude(seq)
- self.assertIn(10**2, d)
- self.assertIn(10**7, d)
- self.assertNotIn(10**4, d)
-
def test_range_step(self):
seq = range(10, 20, 3)
d = SequenceExclude(seq)
@@ -235,6 +231,93 @@ class SequenceExcludeTest(unittest.TestCase):
for v in [-3, "test", 1.1]:
self.assertNotIn(v, d)
+
+class LocalCacheTest(unittest.TestCase):
+
+ def test_cache_with_limit(self):
+ cache = LocalCache(limit=2)
+ cache['a'] = 1
+ cache['b'] = 2
+ cache['c'] = 3
+ self.assertEqual(len(cache), 2)
+ self.assertNotIn('a', cache)
+ self.assertIn('b', cache)
+ self.assertIn('c', cache)
+ self.assertEqual(cache['b'], 2)
+ self.assertEqual(cache['c'], 3)
+
+ def test_cache_without_limit(self):
+ maximum = 10**4
+ cache = LocalCache()
+ for x in range(maximum):
+ cache[str(x)] = x
+ self.assertEqual(len(cache), maximum)
+ for x in range(maximum):
+ self.assertIn(str(x), cache)
+ self.assertEqual(cache[str(x)], x)
+
+
+class LocalWeakReferencedCacheTest(unittest.TestCase):
+
+ def test_cache_with_limit(self):
+ cache = LocalWeakReferencedCache(limit=2)
+ r1 = Request('https://example.org')
+ r2 = Request('https://example.com')
+ r3 = Request('https://example.net')
+ cache[r1] = 1
+ cache[r2] = 2
+ cache[r3] = 3
+ self.assertEqual(len(cache), 2)
+ self.assertNotIn(r1, cache)
+ self.assertIn(r2, cache)
+ self.assertIn(r3, cache)
+ self.assertEqual(cache[r2], 2)
+ self.assertEqual(cache[r3], 3)
+ del r2
+
+ # PyPy takes longer to collect dead references
+ garbage_collect()
+
+ self.assertEqual(len(cache), 1)
+
+ def test_cache_non_weak_referenceable_objects(self):
+ cache = LocalWeakReferencedCache()
+ k1 = None
+ k2 = 1
+ k3 = [1, 2, 3]
+ cache[k1] = 1
+ cache[k2] = 2
+ cache[k3] = 3
+ self.assertNotIn(k1, cache)
+ self.assertNotIn(k2, cache)
+ self.assertNotIn(k3, cache)
+ self.assertEqual(len(cache), 0)
+
+ def test_cache_without_limit(self):
+ max = 10**4
+ cache = LocalWeakReferencedCache()
+ refs = []
+ for x in range(max):
+ refs.append(Request('https://example.org/{}'.format(x)))
+ cache[refs[-1]] = x
+ self.assertEqual(len(cache), max)
+ for i, r in enumerate(refs):
+ self.assertIn(r, cache)
+ self.assertEqual(cache[r], i)
+ del r # delete reference to the last object in the list
+
+ # delete half of the objects, make sure that is reflected in the cache
+ for _ in range(max // 2):
+ refs.pop()
+
+ # PyPy takes longer to collect dead references
+ garbage_collect()
+
+ self.assertEqual(len(cache), max // 2)
+ for i, r in enumerate(refs):
+ self.assertIn(r, cache)
+ self.assertEqual(cache[r], i)
+
+
if __name__ == "__main__":
unittest.main()
-
diff --git a/tests/test_utils_defer.py b/tests/test_utils_defer.py
index 003bb9b02..dfbe71ae2 100644
--- a/tests/test_utils_defer.py
+++ b/tests/test_utils_defer.py
@@ -5,8 +5,6 @@ from twisted.python.failure import Failure
from scrapy.utils.defer import mustbe_deferred, process_chain, \
process_chain_both, process_parallel, iter_errback
-from six.moves import xrange
-
class MustbeDeferredTest(unittest.TestCase):
def test_success_function(self):
@@ -16,8 +14,8 @@ class MustbeDeferredTest(unittest.TestCase):
return steps
dfd = mustbe_deferred(_append, 1)
- dfd.addCallback(self.assertEqual, [1, 2]) # it is [1] with maybeDeferred
- steps.append(2) # add another value, that should be catched by assertEqual
+ dfd.addCallback(self.assertEqual, [1, 2]) # it is [1] with maybeDeferred
+ steps.append(2) # add another value, that should be catched by assertEqual
return dfd
def test_unfired_deferred(self):
@@ -29,18 +27,27 @@ class MustbeDeferredTest(unittest.TestCase):
return dfd
dfd = mustbe_deferred(_append, 1)
- dfd.addCallback(self.assertEqual, [1, 2]) # it is [1] with maybeDeferred
- steps.append(2) # add another value, that should be catched by assertEqual
+ dfd.addCallback(self.assertEqual, [1, 2]) # it is [1] with maybeDeferred
+ steps.append(2) # add another value, that should be catched by assertEqual
return dfd
+
def cb1(value, arg1, arg2):
return "(cb1 %s %s %s)" % (value, arg1, arg2)
+
+
def cb2(value, arg1, arg2):
return defer.succeed("(cb2 %s %s %s)" % (value, arg1, arg2))
+
+
def cb3(value, arg1, arg2):
return "(cb3 %s %s %s)" % (value, arg1, arg2)
+
+
def cb_fail(value, arg1, arg2):
return Failure(TypeError())
+
+
def eb1(failure, arg1, arg2):
return "(eb1 %s %s %s)" % (failure.value.__class__.__name__, arg1, arg2)
@@ -83,7 +90,7 @@ class IterErrbackTest(unittest.TestCase):
def test_iter_errback_good(self):
def itergood():
- for x in xrange(10):
+ for x in range(10):
yield x
errors = []
@@ -93,7 +100,7 @@ class IterErrbackTest(unittest.TestCase):
def test_iter_errback_bad(self):
def iterbad():
- for x in xrange(10):
+ for x in range(10):
if x == 5:
a = 1/0
yield x
diff --git a/tests/test_utils_deprecate.py b/tests/test_utils_deprecate.py
index 3e7236fb1..159ef8f25 100644
--- a/tests/test_utils_deprecate.py
+++ b/tests/test_utils_deprecate.py
@@ -1,13 +1,11 @@
# -*- coding: utf-8 -*-
-from __future__ import absolute_import
import inspect
import unittest
+from unittest import mock
import warnings
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.utils.deprecate import create_deprecated_class, update_classpath
-from tests import mock
-
class MyWarning(UserWarning):
pass
diff --git a/tests/test_utils_http.py b/tests/test_utils_http.py
index 583105673..2fac3da1f 100644
--- a/tests/test_utils_http.py
+++ b/tests/test_utils_http.py
@@ -2,6 +2,7 @@ import unittest
from scrapy.utils.http import decode_chunked_transfer
+
class ChunkedTest(unittest.TestCase):
def test_decode_chunked_transfer(self):
@@ -12,9 +13,7 @@ class ChunkedTest(unittest.TestCase):
chunked_body += "8\r\n" + "sequence\r\n"
chunked_body += "0\r\n\r\n"
body = decode_chunked_transfer(chunked_body)
- self.assertEqual(body, \
- "This is the data in the first chunk\r\n" +
- "and this is the second one\r\n" +
- "consequence")
-
-
+ self.assertEqual(body,
+ "This is the data in the first chunk\r\n" +
+ "and this is the second one\r\n" +
+ "consequence")
diff --git a/tests/test_utils_httpobj.py b/tests/test_utils_httpobj.py
index 4f9f7a370..cf8ad1f23 100644
--- a/tests/test_utils_httpobj.py
+++ b/tests/test_utils_httpobj.py
@@ -1,9 +1,10 @@
import unittest
-from six.moves.urllib.parse import urlparse
+from urllib.parse import urlparse
from scrapy.http import Request
from scrapy.utils.httpobj import urlparse_cached
+
class HttpobjUtilsTest(unittest.TestCase):
def test_urlparse_cached(self):
diff --git a/tests/test_utils_iterators.py b/tests/test_utils_iterators.py
index b2e8610f8..9776dfb2a 100644
--- a/tests/test_utils_iterators.py
+++ b/tests/test_utils_iterators.py
@@ -1,13 +1,14 @@
# -*- coding: utf-8 -*-
import os
-import six
+
from twisted.trial import unittest
from scrapy.utils.iterators import csviter, xmliter, _body_or_str, xmliter_lxml
from scrapy.http import XmlResponse, TextResponse, Response
from tests import get_testdata
-FOOBAR_NL = u"foo" + os.linesep + u"bar"
+
+FOOBAR_NL = u"foo\nbar"
class XmliterTestCase(unittest.TestCase):
@@ -30,10 +31,13 @@ class XmliterTestCase(unittest.TestCase):
response = XmlResponse(url="http://example.com", body=body)
attrs = []
for x in self.xmliter(response, 'product'):
- attrs.append((x.xpath("@id").extract(), x.xpath("name/text()").extract(), x.xpath("./type/text()").extract()))
+ attrs.append((
+ x.attrib['id'],
+ x.xpath("name/text()").getall(),
+ x.xpath("./type/text()").getall()))
self.assertEqual(attrs,
- [(['001'], ['Name 1'], ['Type 1']), (['002'], ['Name 2'], ['Type 2'])])
+ [('001', ['Name 1'], ['Type 1']), ('002', ['Name 2'], ['Type 2'])])
def test_xmliter_unusual_node(self):
body = b"""
@@ -43,7 +47,7 @@ class XmliterTestCase(unittest.TestCase):
"""
response = XmlResponse(url="http://example.com", body=body)
- nodenames = [e.xpath('name()').extract()
+ nodenames = [e.xpath('name()').getall()
for e in self.xmliter(response, 'matchme...')]
self.assertEqual(nodenames, [['matchme...']])
@@ -93,19 +97,19 @@ class XmliterTestCase(unittest.TestCase):
attrs = []
for x in self.xmliter(r, u'þingflokkur'):
- attrs.append((x.xpath('@id').extract(),
- x.xpath(u'./skammstafanir/stuttskammstöfun/text()').extract(),
- x.xpath(u'./tímabil/fyrstaþing/text()').extract()))
+ attrs.append((x.attrib['id'],
+ x.xpath(u'./skammstafanir/stuttskammstöfun/text()').getall(),
+ x.xpath(u'./tímabil/fyrstaþing/text()').getall()))
self.assertEqual(attrs,
- [([u'26'], [u'-'], [u'80']),
- ([u'21'], [u'Ab'], [u'76']),
- ([u'27'], [u'A'], [u'27'])])
+ [(u'26', [u'-'], [u'80']),
+ (u'21', [u'Ab'], [u'76']),
+ (u'27', [u'A'], [u'27'])])
def test_xmliter_text(self):
body = u"""one two """
- self.assertEqual([x.xpath("text()").extract() for x in self.xmliter(body, 'product')],
+ self.assertEqual([x.xpath("text()").getall() for x in self.xmliter(body, 'product')],
[[u'one'], [u'two']])
def test_xmliter_namespaces(self):
@@ -132,15 +136,15 @@ class XmliterTestCase(unittest.TestCase):
node = next(my_iter)
node.register_namespace('g', 'http://base.google.com/ns/1.0')
- self.assertEqual(node.xpath('title/text()').extract(), ['Item 1'])
- self.assertEqual(node.xpath('description/text()').extract(), ['This is item 1'])
- self.assertEqual(node.xpath('link/text()').extract(), ['http://www.mydummycompany.com/items/1'])
- self.assertEqual(node.xpath('g:image_link/text()').extract(), ['http://www.mydummycompany.com/images/item1.jpg'])
- self.assertEqual(node.xpath('g:id/text()').extract(), ['ITEM_1'])
- self.assertEqual(node.xpath('g:price/text()').extract(), ['400'])
- self.assertEqual(node.xpath('image_link/text()').extract(), [])
- self.assertEqual(node.xpath('id/text()').extract(), [])
- self.assertEqual(node.xpath('price/text()').extract(), [])
+ self.assertEqual(node.xpath('title/text()').getall(), ['Item 1'])
+ self.assertEqual(node.xpath('description/text()').getall(), ['This is item 1'])
+ self.assertEqual(node.xpath('link/text()').getall(), ['http://www.mydummycompany.com/items/1'])
+ self.assertEqual(node.xpath('g:image_link/text()').getall(), ['http://www.mydummycompany.com/images/item1.jpg'])
+ self.assertEqual(node.xpath('g:id/text()').getall(), ['ITEM_1'])
+ self.assertEqual(node.xpath('g:price/text()').getall(), ['400'])
+ self.assertEqual(node.xpath('image_link/text()').getall(), [])
+ self.assertEqual(node.xpath('id/text()').getall(), [])
+ self.assertEqual(node.xpath('price/text()').getall(), [])
def test_xmliter_exception(self):
body = u"""one two """
@@ -159,7 +163,7 @@ class XmliterTestCase(unittest.TestCase):
body = b'\n\n - Some Turkish Characters \xd6\xc7\xde\xdd\xd0\xdc \xfc\xf0\xfd\xfe\xe7\xf6
\n \n\n'
response = XmlResponse('http://www.example.com', body=body)
self.assertEqual(
- next(self.xmliter(response, 'item')).extract(),
+ next(self.xmliter(response, 'item')).get(),
u'- Some Turkish Characters \xd6\xc7\u015e\u0130\u011e\xdc \xfc\u011f\u0131\u015f\xe7\xf6
'
)
@@ -192,9 +196,9 @@ class LxmlXmliterTestCase(XmliterTestCase):
namespace_iter = self.xmliter(response, 'image_link', 'http://base.google.com/ns/1.0')
node = next(namespace_iter)
- self.assertEqual(node.xpath('text()').extract(), ['http://www.mydummycompany.com/images/item1.jpg'])
+ self.assertEqual(node.xpath('text()').getall(), ['http://www.mydummycompany.com/images/item1.jpg'])
node = next(namespace_iter)
- self.assertEqual(node.xpath('text()').extract(), ['http://www.mydummycompany.com/images/item2.jpg'])
+ self.assertEqual(node.xpath('text()').getall(), ['http://www.mydummycompany.com/images/item2.jpg'])
def test_xmliter_namespaces_prefix(self):
body = b"""\
@@ -219,19 +223,20 @@ class LxmlXmliterTestCase(XmliterTestCase):
my_iter = self.xmliter(response, 'table', 'http://www.w3.org/TR/html4/', 'h')
node = next(my_iter)
- self.assertEqual(len(node.xpath('h:tr/h:td').extract()), 2)
- self.assertEqual(node.xpath('h:tr/h:td[1]/text()').extract(), ['Apples'])
- self.assertEqual(node.xpath('h:tr/h:td[2]/text()').extract(), ['Bananas'])
+ self.assertEqual(len(node.xpath('h:tr/h:td').getall()), 2)
+ self.assertEqual(node.xpath('h:tr/h:td[1]/text()').getall(), ['Apples'])
+ self.assertEqual(node.xpath('h:tr/h:td[2]/text()').getall(), ['Bananas'])
my_iter = self.xmliter(response, 'table', 'http://www.w3schools.com/furniture', 'f')
node = next(my_iter)
- self.assertEqual(node.xpath('f:name/text()').extract(), ['African Coffee Table'])
+ self.assertEqual(node.xpath('f:name/text()').getall(), ['African Coffee Table'])
def test_xmliter_objtype_exception(self):
i = self.xmliter(42, 'product')
self.assertRaises(TypeError, next, i)
+
class UtilsCsvTestCase(unittest.TestCase):
sample_feeds_dir = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'sample_data', 'feeds')
sample_feed_path = os.path.join(sample_feeds_dir, 'feed-sample3.csv')
@@ -252,8 +257,8 @@ class UtilsCsvTestCase(unittest.TestCase):
# explicit type check cuz' we no like stinkin' autocasting! yarrr
for result_row in result:
- self.assertTrue(all((isinstance(k, six.text_type) for k in result_row.keys())))
- self.assertTrue(all((isinstance(v, six.text_type) for v in result_row.values())))
+ self.assertTrue(all((isinstance(k, str) for k in result_row.keys())))
+ self.assertTrue(all((isinstance(v, str) for v in result_row.values())))
def test_csviter_delimiter(self):
body = get_testdata('feeds', 'feed-sample3.csv').replace(b',', b'\t')
diff --git a/tests/test_utils_log.py b/tests/test_utils_log.py
index 45527b03b..2c23f3616 100644
--- a/tests/test_utils_log.py
+++ b/tests/test_utils_log.py
@@ -1,5 +1,4 @@
# -*- coding: utf-8 -*-
-from __future__ import print_function
import sys
import logging
import unittest
@@ -10,6 +9,7 @@ from twisted.python.failure import Failure
from scrapy.utils.log import (failure_to_exc_info, TopLevelFormatter,
LogCounterHandler, StreamLogger)
from scrapy.utils.test import get_crawler
+from scrapy.extensions import telnet
class FailureToExcInfoTest(unittest.TestCase):
@@ -65,10 +65,14 @@ class TopLevelFormatterTest(unittest.TestCase):
class LogCounterHandlerTest(unittest.TestCase):
def setUp(self):
+ settings = {'LOG_LEVEL': 'WARNING'}
+ if not telnet.TWISTED_CONCH_AVAILABLE:
+ # disable it to avoid the extra warning
+ settings['TELNETCONSOLE_ENABLED'] = False
self.logger = logging.getLogger('test')
self.logger.setLevel(logging.NOTSET)
self.logger.propagate = False
- self.crawler = get_crawler(settings_dict={'LOG_LEVEL': 'WARNING'})
+ self.crawler = get_crawler(settings_dict=settings)
self.handler = LogCounterHandler(self.crawler)
self.logger.addHandler(self.handler)
diff --git a/tests/test_utils_misc/__init__.py b/tests/test_utils_misc/__init__.py
index 832253aa4..6f945cd01 100644
--- a/tests/test_utils_misc/__init__.py
+++ b/tests/test_utils_misc/__init__.py
@@ -1,12 +1,15 @@
import sys
import os
import unittest
+from unittest import mock
from scrapy.item import Item, Field
-from scrapy.utils.misc import load_object, arg_to_iter, walk_modules
+from scrapy.utils.misc import arg_to_iter, create_instance, load_object, set_environ, walk_modules
+
__doctests__ = ['scrapy.utils.misc']
+
class UtilsMiscTestCase(unittest.TestCase):
def test_load_object(self):
@@ -71,8 +74,75 @@ class UtilsMiscTestCase(unittest.TestCase):
self.assertEqual(list(arg_to_iter(100)), [100])
self.assertEqual(list(arg_to_iter(l for l in 'abc')), ['a', 'b', 'c'])
self.assertEqual(list(arg_to_iter([1, 2, 3])), [1, 2, 3])
- self.assertEqual(list(arg_to_iter({'a':1})), [{'a': 1}])
+ self.assertEqual(list(arg_to_iter({'a': 1})), [{'a': 1}])
self.assertEqual(list(arg_to_iter(TestItem(name="john"))), [TestItem(name="john")])
+ def test_create_instance(self):
+ settings = mock.MagicMock()
+ crawler = mock.MagicMock(spec_set=['settings'])
+ args = (True, 100.)
+ kwargs = {'key': 'val'}
+
+ def _test_with_settings(mock, settings):
+ create_instance(mock, settings, None, *args, **kwargs)
+ if hasattr(mock, 'from_crawler'):
+ self.assertEqual(mock.from_crawler.call_count, 0)
+ if hasattr(mock, 'from_settings'):
+ mock.from_settings.assert_called_once_with(settings, *args,
+ **kwargs)
+ self.assertEqual(mock.call_count, 0)
+ else:
+ mock.assert_called_once_with(*args, **kwargs)
+
+ def _test_with_crawler(mock, settings, crawler):
+ create_instance(mock, settings, crawler, *args, **kwargs)
+ if hasattr(mock, 'from_crawler'):
+ mock.from_crawler.assert_called_once_with(crawler, *args,
+ **kwargs)
+ if hasattr(mock, 'from_settings'):
+ self.assertEqual(mock.from_settings.call_count, 0)
+ self.assertEqual(mock.call_count, 0)
+ elif hasattr(mock, 'from_settings'):
+ mock.from_settings.assert_called_once_with(settings, *args,
+ **kwargs)
+ self.assertEqual(mock.call_count, 0)
+ else:
+ mock.assert_called_once_with(*args, **kwargs)
+
+ # Check usage of correct constructor using four mocks:
+ # 1. with no alternative constructors
+ # 2. with from_settings() constructor
+ # 3. with from_crawler() constructor
+ # 4. with from_settings() and from_crawler() constructor
+ spec_sets = ([], ['from_settings'], ['from_crawler'],
+ ['from_settings', 'from_crawler'])
+ for specs in spec_sets:
+ m = mock.MagicMock(spec_set=specs)
+ _test_with_settings(m, settings)
+ m.reset_mock()
+ _test_with_crawler(m, settings, crawler)
+
+ # Check adoption of crawler settings
+ m = mock.MagicMock(spec_set=['from_settings'])
+ create_instance(m, None, crawler, *args, **kwargs)
+ m.from_settings.assert_called_once_with(crawler.settings, *args,
+ **kwargs)
+
+ 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
+
+ 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()
diff --git a/tests/test_utils_misc/test_return_with_argument_inside_generator.py b/tests/test_utils_misc/test_return_with_argument_inside_generator.py
new file mode 100644
index 000000000..bdbec1beb
--- /dev/null
+++ b/tests/test_utils_misc/test_return_with_argument_inside_generator.py
@@ -0,0 +1,37 @@
+import unittest
+
+from scrapy.utils.misc import is_generator_with_return_value
+
+
+class UtilsMiscPy3TestCase(unittest.TestCase):
+
+ def test_generators_with_return_statements(self):
+ def f():
+ yield 1
+ return 2
+
+ def g():
+ yield 1
+ return 'asdf'
+
+ def h():
+ yield 1
+ return None
+
+ def i():
+ yield 1
+ return
+
+ def j():
+ yield 1
+
+ def k():
+ yield 1
+ yield from g()
+
+ assert is_generator_with_return_value(f)
+ assert is_generator_with_return_value(g)
+ assert not is_generator_with_return_value(h)
+ assert not is_generator_with_return_value(i)
+ assert not is_generator_with_return_value(j)
+ assert not is_generator_with_return_value(k) # not recursive
diff --git a/tests/test_utils_project.py b/tests/test_utils_project.py
index 7e2caace8..bd74b0c34 100644
--- a/tests/test_utils_project.py
+++ b/tests/test_utils_project.py
@@ -25,8 +25,12 @@ def inside_a_project():
class ProjectUtilsTest(unittest.TestCase):
def test_data_path_outside_project(self):
- self.assertEqual('.scrapy/somepath', data_path('somepath'))
- self.assertEqual('/absolute/path', data_path('/absolute/path'))
+ self.assertEqual(
+ os.path.join('.scrapy', 'somepath'),
+ data_path('somepath')
+ )
+ abspath = os.path.join(os.path.sep, 'absolute', 'path')
+ self.assertEqual(abspath, data_path(abspath))
def test_data_path_inside_project(self):
with inside_a_project() as proj_path:
@@ -35,4 +39,5 @@ class ProjectUtilsTest(unittest.TestCase):
os.path.realpath(expected),
os.path.realpath(data_path('somepath'))
)
- self.assertEqual('/absolute/path', data_path('/absolute/path'))
+ abspath = os.path.join(os.path.sep, 'absolute', 'path')
+ self.assertEqual(abspath, data_path(abspath))
diff --git a/tests/test_utils_python.py b/tests/test_utils_python.py
index f6133657b..b79e0ac1c 100644
--- a/tests/test_utils_python.py
+++ b/tests/test_utils_python.py
@@ -1,19 +1,36 @@
-import gc
import functools
+import gc
import operator
+import platform
import unittest
from itertools import count
-import platform
-import six
+from warnings import catch_warnings
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)
+ WeakKeyCache, get_func_args, to_bytes, to_unicode,
+ 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)
+ with catch_warnings(record=True) as warnings:
+ self.assertEqual(m.next(), 2)
+ self.assertEqual(len(warnings), 1)
+ self.assertIn('scrapy.utils.python.MutableChain.__next__',
+ str(warnings[0].message))
+ 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')
@@ -151,33 +168,6 @@ class UtilsPythonTestCase(unittest.TestCase):
gc.collect()
self.assertFalse(len(wk._weakdict))
- @unittest.skipUnless(six.PY2, "deprecated function")
- def test_stringify_dict(self):
- d = {'a': 123, u'b': b'c', u'd': u'e', object(): u'e'}
- d2 = stringify_dict(d, keys_only=False)
- self.assertEqual(d, d2)
- self.assertIsNot(d, d2) # shouldn't modify in place
- self.assertFalse(any(isinstance(x, six.text_type) for x in d2.keys()))
- self.assertFalse(any(isinstance(x, six.text_type) for x in d2.values()))
-
- @unittest.skipUnless(six.PY2, "deprecated function")
- def test_stringify_dict_tuples(self):
- tuples = [('a', 123), (u'b', 'c'), (u'd', u'e'), (object(), u'e')]
- d = dict(tuples)
- d2 = stringify_dict(tuples, keys_only=False)
- self.assertEqual(d, d2)
- self.assertIsNot(d, d2) # shouldn't modify in place
- self.assertFalse(any(isinstance(x, six.text_type) for x in d2.keys()), d2.keys())
- self.assertFalse(any(isinstance(x, six.text_type) for x in d2.values()))
-
- @unittest.skipUnless(six.PY2, "deprecated function")
- def test_stringify_dict_keys_only(self):
- d = {'a': 123, u'b': 'c', u'd': u'e', object(): u'e'}
- d2 = stringify_dict(d)
- self.assertEqual(d, d2)
- self.assertIsNot(d, d2) # shouldn't modify in place
- self.assertFalse(any(isinstance(x, six.text_type) for x in d2.keys()))
-
def test_get_func_args(self):
def f1(a, b, c):
pass
@@ -215,16 +205,15 @@ class UtilsPythonTestCase(unittest.TestCase):
if platform.python_implementation() == 'CPython':
# TODO: how do we fix this to return the actual argument names?
- self.assertEqual(get_func_args(six.text_type.split), [])
+ self.assertEqual(get_func_args(str.split), [])
self.assertEqual(get_func_args(" ".join), [])
self.assertEqual(get_func_args(operator.itemgetter(2)), [])
else:
- stripself = not six.PY2 # PyPy3 exposes them as methods
self.assertEqual(
- get_func_args(six.text_type.split, stripself), ['sep', 'maxsplit'])
- self.assertEqual(get_func_args(" ".join, stripself), ['list'])
+ get_func_args(str.split, stripself=True), ['sep', 'maxsplit'])
+ self.assertEqual(get_func_args(" ".join, stripself=True), ['list'])
self.assertEqual(
- get_func_args(operator.itemgetter(2), stripself), ['obj'])
+ get_func_args(operator.itemgetter(2), stripself=True), ['obj'])
def test_without_none_values(self):
diff --git a/tests/test_utils_reqser.py b/tests/test_utils_reqser.py
index dcc070b8f..06d9c004c 100644
--- a/tests/test_utils_reqser.py
+++ b/tests/test_utils_reqser.py
@@ -1,9 +1,8 @@
-# -*- coding: utf-8 -*-
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, _is_private_method, _mangle_private_name
class RequestSerializationTest(unittest.TestCase):
@@ -26,6 +25,7 @@ class RequestSerializationTest(unittest.TestCase):
encoding='latin-1',
priority=20,
meta={'a': 'b'},
+ cb_kwargs={'k': 'v'},
flags=['testFlag'])
self._assert_serializes_ok(r, spider=self.spider)
@@ -52,6 +52,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.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)
@@ -68,6 +69,53 @@ 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_mixin_private_callback_serialization(self):
+ 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_'))
+ 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(_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 _assert_mangles_to(self, obj, name):
+ func = getattr(obj, name)
+ self.assertEqual(
+ _mangle_private_name(obj, func, func.__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)
@@ -78,7 +126,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):
@@ -87,6 +140,9 @@ class TestSpider(Spider):
def handle_error(self, failure):
pass
+ def __parse_item_private(self, response):
+ pass
+
class CustomRequest(Request):
pass
diff --git a/tests/test_utils_request.py b/tests/test_utils_request.py
index e8a4eb3ea..3e664fc74 100644
--- a/tests/test_utils_request.py
+++ b/tests/test_utils_request.py
@@ -1,9 +1,9 @@
-from __future__ import print_function
import unittest
from scrapy.http import Request
from scrapy.utils.request import request_fingerprint, _fingerprint_cache, \
request_authenticate, request_httprepr
+
class UtilsRequestTest(unittest.TestCase):
def test_request_fingerprint(self):
@@ -17,7 +17,7 @@ class UtilsRequestTest(unittest.TestCase):
self.assertNotEqual(request_fingerprint(r1), request_fingerprint(r2))
# make sure caching is working
- self.assertEqual(request_fingerprint(r1), _fingerprint_cache[r1][None])
+ self.assertEqual(request_fingerprint(r1), _fingerprint_cache[r1][(None, False)])
r1 = Request("http://www.example.com/members/offers.html")
r2 = Request("http://www.example.com/members/offers.html")
@@ -42,6 +42,13 @@ class UtilsRequestTest(unittest.TestCase):
self.assertEqual(request_fingerprint(r3, include_headers=['accept-language', 'sessionid']),
request_fingerprint(r3, include_headers=['SESSIONID', 'Accept-Language']))
+ r1 = Request("http://www.example.com/test.html")
+ r2 = Request("http://www.example.com/test.html#fragment")
+ self.assertEqual(request_fingerprint(r1), request_fingerprint(r2))
+ self.assertEqual(request_fingerprint(r1), request_fingerprint(r1, keep_fragments=True))
+ self.assertNotEqual(request_fingerprint(r2), request_fingerprint(r2, keep_fragments=True))
+ self.assertNotEqual(request_fingerprint(r1), request_fingerprint(r2, keep_fragments=True))
+
r1 = Request("http://www.example.com")
r2 = Request("http://www.example.com", method='POST')
r3 = Request("http://www.example.com", method='POST', body=b'request body')
diff --git a/tests/test_utils_response.py b/tests/test_utils_response.py
index bea4dade3..6ebf290c0 100644
--- a/tests/test_utils_response.py
+++ b/tests/test_utils_response.py
@@ -1,12 +1,13 @@
import os
import unittest
-from six.moves.urllib.parse import urlparse
+from urllib.parse import urlparse
from scrapy.http import Response, TextResponse, HtmlResponse
from scrapy.utils.python import to_bytes
from scrapy.utils.response import (response_httprepr, open_in_browser,
get_meta_refresh, get_base_url, response_status_message)
+
__doctests__ = ['scrapy.utils.response']
diff --git a/tests/test_utils_signal.py b/tests/test_utils_signal.py
index 62edd420d..16b7c5c68 100644
--- a/tests/test_utils_signal.py
+++ b/tests/test_utils_signal.py
@@ -66,6 +66,7 @@ class SendCatchLogDeferredTest2(SendCatchLogTest):
def _get_result(self, signal, *a, **kw):
return send_catch_log_deferred(signal, *a, **kw)
+
class SendCatchLogTest2(unittest.TestCase):
def test_error_logged_if_deferred_not_supported(self):
diff --git a/tests/test_utils_sitemap.py b/tests/test_utils_sitemap.py
index 716bb44eb..db323ab31 100644
--- a/tests/test_utils_sitemap.py
+++ b/tests/test_utils_sitemap.py
@@ -2,6 +2,7 @@ import unittest
from scrapy.utils.sitemap import Sitemap, sitemap_urls_from_robots
+
class SitemapTest(unittest.TestCase):
def test_sitemap(self):
diff --git a/tests/test_utils_spider.py b/tests/test_utils_spider.py
index 045e72117..ee7d17062 100644
--- a/tests/test_utils_spider.py
+++ b/tests/test_utils_spider.py
@@ -1,20 +1,19 @@
import unittest
+
+from scrapy import Spider
from scrapy.http import Request
from scrapy.item import BaseItem
from scrapy.utils.spider import iterate_spider_output, iter_spider_classes
-from scrapy.spiders import CrawlSpider
-
-class MyBaseSpider(CrawlSpider):
- pass # abstract spider
-
-class MySpider1(MyBaseSpider):
+class MySpider1(Spider):
name = 'myspider1'
-class MySpider2(MyBaseSpider):
+
+class MySpider2(Spider):
name = 'myspider2'
+
class UtilsSpidersTestCase(unittest.TestCase):
def test_iterate_spider_output(self):
@@ -32,6 +31,6 @@ class UtilsSpidersTestCase(unittest.TestCase):
it = iter_spider_classes(tests.test_utils_spider)
self.assertEqual(set(it), {MySpider1, MySpider2})
+
if __name__ == "__main__":
unittest.main()
-
diff --git a/tests/test_utils_trackref.py b/tests/test_utils_trackref.py
index c6072fc0d..16e02f919 100644
--- a/tests/test_utils_trackref.py
+++ b/tests/test_utils_trackref.py
@@ -1,7 +1,8 @@
-import six
import unittest
+from io import StringIO
+from unittest import mock
+
from scrapy.utils import trackref
-from tests import mock
class Foo(trackref.object_ref):
@@ -38,12 +39,12 @@ Live References
Bar 1 oldest: 0s ago
''')
- @mock.patch('sys.stdout', new_callable=six.StringIO)
+ @mock.patch('sys.stdout', new_callable=StringIO)
def test_print_live_refs_empty(self, stdout):
trackref.print_live_refs()
self.assertEqual(stdout.getvalue(), 'Live References\n\n\n')
- @mock.patch('sys.stdout', new_callable=six.StringIO)
+ @mock.patch('sys.stdout', new_callable=StringIO)
def test_print_live_refs_with_objects(self, stdout):
o1 = Foo() # NOQA
trackref.print_live_refs()
diff --git a/tests/test_utils_url.py b/tests/test_utils_url.py
index c2b9fc176..21e9a056a 100644
--- a/tests/test_utils_url.py
+++ b/tests/test_utils_url.py
@@ -1,13 +1,10 @@
# -*- coding: utf-8 -*-
import unittest
-import six
-from six.moves.urllib.parse import urlparse
-
from scrapy.spiders import Spider
from scrapy.utils.url import (url_is_from_any_domain, url_is_from_spider,
- add_http_if_no_scheme, guess_scheme,
- parse_url, strip_url)
+ add_http_if_no_scheme, guess_scheme, strip_url)
+
__doctests__ = ['scrapy.utils.url']
@@ -187,6 +184,7 @@ class AddHttpIfNoScheme(unittest.TestCase):
class GuessSchemeTest(unittest.TestCase):
pass
+
def create_guess_scheme_t(args):
def do_expected(self):
url = guess_scheme(args[0])
@@ -195,6 +193,7 @@ def create_guess_scheme_t(args):
args[0], url, args[1])
return do_expected
+
def create_skipped_scheme_t(args):
def do_expected(self):
raise unittest.SkipTest(args[2])
@@ -233,8 +232,8 @@ for k, args in enumerate ([
setattr (GuessSchemeTest, t_method.__name__, t_method)
# TODO: the following tests do not pass with current implementation
-for k, args in enumerate ([
- ('C:\absolute\path\to\a\file.html', 'file://',
+for k, args in enumerate([
+ (r'C:\absolute\path\to\a\file.html', 'file://',
'Windows filepath are not supported for scrapy shell'),
], start=1):
t_method = create_skipped_scheme_t(args)
diff --git a/tests/test_webclient.py b/tests/test_webclient.py
index 766329b57..746367b41 100644
--- a/tests/test_webclient.py
+++ b/tests/test_webclient.py
@@ -3,9 +3,9 @@ from twisted.internet import defer
Tests borrowed from the twisted.web.client tests.
"""
import os
-import six
import shutil
+import OpenSSL.SSL
from twisted.trial import unittest
from twisted.web import server, static, util, resource
from twisted.internet import reactor, defer
@@ -15,8 +15,12 @@ from twisted.protocols.policies import WrappingFactory
from twisted.internet.defer import inlineCallbacks
from scrapy.core.downloader import webclient as client
+from scrapy.core.downloader.contextfactory import ScrapyClientContextFactory
from scrapy.http import Request, Headers
+from scrapy.settings import Settings
+from scrapy.utils.misc import create_instance
from scrapy.utils.python import to_bytes, to_unicode
+from tests.mockserver import ssl_context_factory
def getPage(url, contextFactory=None, response_transform=None, *args, **kwargs):
@@ -73,26 +77,6 @@ class ParseUrlTestCase(unittest.TestCase):
to_bytes(x) if not isinstance(x, int) else x for x in test)
self.assertEqual(client._parse(url), test, url)
- def test_externalUnicodeInterference(self):
- """
- L{client._parse} should return C{str} for the scheme, host, and path
- elements of its return tuple, even when passed an URL which has
- previously been passed to L{urlparse} as a C{unicode} string.
- """
- if not six.PY2:
- raise unittest.SkipTest(
- "Applies only to Py2, as urls can be ONLY unicode on Py3")
- badInput = u'http://example.com/path'
- goodInput = badInput.encode('ascii')
- self._parse(badInput) # cache badInput in urlparse_cached
- scheme, netloc, host, port, path = self._parse(goodInput)
- self.assertTrue(isinstance(scheme, str))
- self.assertTrue(isinstance(netloc, str))
- self.assertTrue(isinstance(host, str))
- self.assertTrue(isinstance(path, str))
- self.assertTrue(isinstance(port, int))
-
-
class ScrapyHTTPPageGetterTests(unittest.TestCase):
@@ -313,7 +297,7 @@ class WebClientTestCase(unittest.TestCase):
def cleanup(passthrough):
# Clean up the server which is hanging around not doing
# anything.
- connected = list(six.iterkeys(self.wrapper.protocols))
+ connected = list(self.wrapper.protocols.keys())
# There might be nothing here if the server managed to already see
# that the connection was lost.
if connected:
@@ -363,3 +347,55 @@ class WebClientTestCase(unittest.TestCase):
self.assertEqual(content_encoding, EncodingResource.out_encoding)
self.assertEqual(
response.body.decode(content_encoding), to_unicode(original_body))
+
+
+class WebClientSSLTestCase(unittest.TestCase):
+ context_factory = None
+
+ def _listen(self, site):
+ return reactor.listenSSL(
+ 0, site,
+ contextFactory=self.context_factory or ssl_context_factory(),
+ interface="127.0.0.1")
+
+ def getURL(self, path):
+ return "https://127.0.0.1:%d/%s" % (self.portno, path)
+
+ def setUp(self):
+ self.tmpname = self.mktemp()
+ os.mkdir(self.tmpname)
+ FilePath(self.tmpname).child("file").setContent(b"0123456789")
+ r = static.File(self.tmpname)
+ r.putChild(b"payload", PayloadResource())
+ self.site = server.Site(r, timeout=None)
+ self.wrapper = WrappingFactory(self.site)
+ self.port = self._listen(self.wrapper)
+ self.portno = self.port.getHost().port
+
+ @inlineCallbacks
+ def tearDown(self):
+ yield self.port.stopListening()
+ shutil.rmtree(self.tmpname)
+
+ def testPayload(self):
+ s = "0123456789" * 10
+ return getPage(self.getURL("payload"), body=s).addCallback(
+ self.assertEqual, to_bytes(s))
+
+
+class WebClientCustomCiphersSSLTestCase(WebClientSSLTestCase):
+ # we try to use a cipher that is not enabled by default in OpenSSL
+ custom_ciphers = 'CAMELLIA256-SHA'
+ context_factory = ssl_context_factory(cipher_string=custom_ciphers)
+
+ def testPayload(self):
+ s = "0123456789" * 10
+ settings = Settings({'DOWNLOADER_CLIENT_TLS_CIPHERS': self.custom_ciphers})
+ client_context_factory = create_instance(ScrapyClientContextFactory, settings=settings, crawler=None)
+ return getPage(self.getURL("payload"), body=s,
+ contextFactory=client_context_factory).addCallback(self.assertEqual, to_bytes(s))
+
+ def testPayloadDefaultCiphers(self):
+ s = "0123456789" * 10
+ d = getPage(self.getURL("payload"), body=s, contextFactory=ScrapyClientContextFactory())
+ return self.assertFailure(d, OpenSSL.SSL.Error)
diff --git a/tox.ini b/tox.ini
index c2fa9af28..b62100026 100644
--- a/tox.ini
+++ b/tox.ini
@@ -4,18 +4,16 @@
# and then run "tox" from this directory.
[tox]
-envlist = py27
+envlist = security,flake8,py3
+minversion = 1.7.0
[testenv]
deps =
-ctests/constraints.txt
- -rrequirements.txt
+ -rtests/requirements-py3.txt
# Extras
- botocore
- google-cloud-storage
- Pillow != 3.0.0
- leveldb
- -rtests/requirements.txt
+ botocore>=1.3.23
+ Pillow>=3.4.2
passenv =
S3_TEST_FILE_URI
AWS_ACCESS_KEY_ID
@@ -23,68 +21,55 @@ passenv =
GCS_TEST_FILE_URI
GCS_PROJECT_ID
commands =
- py.test --cov=scrapy --cov-report= {posargs:scrapy tests}
+ py.test --cov=scrapy --cov-report= {posargs:--durations=10 docs scrapy tests}
-[testenv:trusty]
-basepython = python2.7
+[testenv:security]
+basepython = python3
deps =
- pyOpenSSL==0.13
- lxml==3.3.3
- Twisted==13.2.0
- boto==2.20.1
- Pillow==2.3.0
- cssselect==0.9.1
- zope.interface==4.0.5
- -rtests/requirements.txt
-
-[testenv:jessie]
-# https://packages.debian.org/en/jessie/python/
-# https://packages.debian.org/en/jessie/zope/
-basepython = python2.7
-deps =
- cryptography==0.6.1
- pyOpenSSL==0.14
- lxml==3.4.0
- Twisted==14.0.2
- boto==2.34.0
- Pillow==2.6.1
- cssselect==0.9.1
- zope.interface==4.1.1
- -rtests/requirements.txt
-
-[testenv:trunk]
-basepython = python2.7
+ bandit
commands =
- pip install -U https://github.com/scrapy/w3lib/archive/master.zip#egg=w3lib
- pip install -U https://github.com/scrapy/queuelib/archive/master.zip#egg=queuelib
- py.test --cov=scrapy --cov-report= {posargs:scrapy tests}
+ bandit -r -c .bandit.yml {posargs:scrapy}
-[testenv:pypy]
-basepython = pypy
-commands =
- py.test {posargs:scrapy tests}
-
-[testenv:py34]
-basepython = python3.4
+[testenv:flake8]
+basepython = python3
deps =
- -rrequirements-py3.txt
- # Extras
- Pillow
- -rtests/requirements-py3.txt
-
-[testenv:py35]
-basepython = python3.5
-deps = {[testenv:py34]deps}
-
-[testenv:py36]
-basepython = python3.6
-deps = {[testenv:py34]deps}
+ {[testenv]deps}
+ pytest-flake8
+commands =
+ py.test --flake8 {posargs:docs scrapy tests}
[testenv:pypy3]
basepython = pypy3
-deps = {[testenv:py34]deps}
commands =
- py.test {posargs:scrapy tests}
+ py.test {posargs:--durations=10 docs scrapy tests}
+
+[testenv:pinned]
+basepython = python3
+deps =
+ -ctests/constraints.txt
+ cryptography==2.0
+ cssselect==0.9.1
+ lxml==3.5.0
+ parsel==1.5.0
+ Protego==0.1.15
+ PyDispatcher==2.0.5
+ pyOpenSSL==16.2.0
+ queuelib==1.4.2
+ service_identity==16.0.0
+ six==1.10.0
+ Twisted==17.9.0
+ w3lib==1.17.0
+ zope.interface==4.1.3
+ -rtests/requirements-py3.txt
+ # Extras
+ botocore==1.3.23
+ Pillow==3.4.2
+
+[testenv:extra-deps]
+deps =
+ {[testenv]deps}
+ reppy
+ robotexclusionrulesparser
[docs]
changedir = docs
@@ -92,13 +77,36 @@ deps =
-rdocs/requirements.txt
[testenv:docs]
+basepython = python3
changedir = {[docs]changedir}
deps = {[docs]deps}
commands =
sphinx-build -W -b html . {envtmpdir}/html
+[testenv:docs-coverage]
+basepython = python3
+changedir = {[docs]changedir}
+deps = {[docs]deps}
+commands =
+ sphinx-build -b coverage . {envtmpdir}/coverage
+
[testenv:docs-links]
+basepython = python3
changedir = {[docs]changedir}
deps = {[docs]deps}
commands =
sphinx-build -W -b linkcheck . {envtmpdir}/linkcheck
+
+[asyncio]
+commands =
+ {[testenv]commands} --reactor=asyncio
+
+[testenv:py35-asyncio]
+basepython = python3.5
+deps = {[testenv]deps}
+commands = {[asyncio]commands}
+
+[testenv:py38-asyncio]
+basepython = python3.8
+deps = {[testenv]deps}
+commands = {[asyncio]commands}