Compare commits

...

42 Commits

Author SHA1 Message Date
Adrián Chaves e8cb5a03b3 Bump version: 2.11.1 → 2.11.2 2024-05-14 13:23:23 +02:00
Adrián Chaves 2c031f4061 Set the release date of 2.11.2 2024-05-14 13:23:13 +02:00
Adrián Chaves 3ffa17c020 Use posargs for pypy3-pinned 2024-05-14 13:19:07 +02:00
Adrián Chaves c6a8f0e4d9 Update VERSION references 2024-05-13 20:20:20 +02:00
Adrián Chaves 60d2577284 Merge remote-tracking branch '23j4/2.11.2-release-notes' into 2.11 2024-05-13 20:16:23 +02:00
Adrián Chaves 36287cb665 Merge branch 'redirect-protocols' into 2.11 2024-05-13 20:09:00 +02:00
Adrián Chaves f138d5d145 Merge branch 'environ-proxy-protocol' into 2.11 2024-05-13 20:06:15 +02:00
Adrián Chaves 1d0502f25b Merge branch 'advisory-fix' into 2.11 2024-05-13 19:51:49 +02:00
Adrián Chaves bb948af00b
Release notes for 2.11.2 (#6359) 2024-05-13 19:49:37 +02:00
Adrián Chaves 5ad9433dd5 Merge remote-tracking branch 'scrapy/2.11' into 2.11 2024-05-13 16:27:40 +02:00
Kevin Toms ac5d448054 Add FAQ on making a blank request (#6208) 2024-05-13 16:26:26 +02:00
Adrián Chaves a6c89a2e0a scrapy parse: fix the signature of callbacks from the CLI (#6182) 2024-05-13 16:25:07 +02:00
Adrián Chaves 67c77eb9f2 MediaPipeline: log media_to_download errors before stripping them (#5068) 2024-05-13 16:24:51 +02:00
nihilisticneuralnet 1d6d2a8751 [Solved] JOBDIR= None for when Scheduler initializes disk queue even if JOBDIR is empty string (#6124)
Co-authored-by: John Doe <johndoe@email.com>
2024-05-13 16:23:10 +02:00
Bagaudin Magomedov fa4c42dfd7 Add SpiderLoggerAdapter, change Spider.logger to return SpiderLoggerAdapter (#6324) 2024-05-13 16:22:46 +02:00
Sanchay Kumar 7ad9eb64c4 Indicate that Selector.type can be json (#6334) 2024-05-13 16:22:31 +02:00
Lorenzo Verardo 1f4523f1a2 Handle robots.txt files not UTF-8 encoded (#6298) 2024-05-13 16:21:57 +02:00
Víctor Ruiz 915f40fa81 Fix WrappedRequest.get_header raising TypeError if default is None (#6310) 2024-05-13 16:21:00 +02:00
Jalil SA 8c0bc1b698 fix: LxmlLinkExtractor unique_list missing key (#6221) 2024-05-13 16:19:52 +02:00
Andrey Rakhmatullin 454bd13a8d Remove the auto-generated copyright years from the docs footer. (#6322) 2024-05-13 16:16:34 +02:00
Adrián Chaves b25f34df69 Bandit: allow-list lxml usages (#6265) 2024-05-13 16:13:34 +02:00
Adrián Chaves 82981fb8a2 Do not close the underlying file from compression plugins (#6239) 2024-05-13 16:10:19 +02:00
Adrián Chaves f149ea4b80
Fix the offsite middleware missing some requests (#6358) 2024-05-13 15:32:53 +02:00
aisha-partha 3562618f67 Set METAREFRESH_IGNORE_TAGS to ['noscript'] by default (#6347) 2024-05-13 14:23:15 +02:00
Andrey Rakhmatullin 397d21f1f5 Cherry-pick WindowsRunSpiderCommandTest fixes (#6290). 2024-05-08 16:42:30 +05:00
Andrey Rakhmatullin 665c84e92c Update MANIFEST.in. 2024-05-08 12:44:50 +05:00
kokobhara bb4ff4d5cd Fix test expectations (#6316) 2024-04-27 20:23:35 +05:00
Adrián Chaves 7983aead92 Release notes for Scrapy 2.11.2 2024-04-11 20:39:10 +02:00
Adrián Chaves 070f13e7c7
Update practices.rst 2024-04-11 12:18:56 +02:00
Adrián Chaves 7a1ab7e1be Do not drop Cookie on http → http 2024-03-06 16:19:19 +01:00
Adrián Chaves 6499214a4f Keep the Cookie header on scheme upgrades 2024-03-06 15:40:13 +01:00
Tsubasa Umeuchi f8d6c456e0 Fixed logic for handling headers on redirect 2024-03-02 14:02:27 +09:00
Yuri H. Galvao 22ee980dd0 Restore brotlipy support (#6261) 2024-03-01 11:40:10 +01:00
Adrián Chaves 1932722277 Use defusedxml.xmlrpc 2024-02-29 09:50:29 +01:00
Adrián Chaves 12cd4f39c8 Fix bug 2024-02-27 23:20:53 +01:00
Adrián Chaves 247e24e5f4 Do not set _scheme_proxy unless necessary 2024-02-27 23:00:49 +01:00
Adrián Chaves c5446c0b85 Fix proxy handling on redirect to different scheme 2024-02-27 22:41:27 +01:00
Adrián Chaves 3cbb6fe596 Merge remote-tracking branch 'scrapy/2.11' into redirect-protocols 2024-02-21 14:38:29 +01:00
Adrián Chaves 11b610dfda
Address tox issues (#6240)
* Force Python 3.8 in pinned tox envs

* Force werkzeug < 3 when using mitmproxy
2024-02-21 14:28:39 +01:00
Adrián Chaves c04bba9e0a Force werkzeug < 3 when using mitmproxy 2024-02-20 15:31:10 +01:00
Adrián Chaves 685cf5940f Fix MetaRefreshMiddleware 2024-02-20 13:57:06 +01:00
Adrián Chaves 33ef5450f9 Fix RedirectMiddleware 2024-02-20 13:36:59 +01:00
59 changed files with 2290 additions and 444 deletions

View File

@ -7,14 +7,12 @@ skips:
- B306
- B307
- B311
- B320
- B321
- B324
- B402 # https://github.com/scrapy/scrapy/issues/4180
- B403
- B404
- B406
- B410
- B503
- B603
- B605

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 2.11.1
current_version = 2.11.2
commit = True
tag = True
tag_name = {new_version}

View File

@ -1,9 +1,8 @@
include README.rst
include AUTHORS
include INSTALL
include LICENSE
include MANIFEST.in
include CODE_OF_CONDUCT.md
include CONTRIBUTING.md
include INSTALL.md
include NEWS
include SECURITY.md
include scrapy/VERSION
include scrapy/mime.types
@ -11,16 +10,13 @@ include scrapy/mime.types
include codecov.yml
include conftest.py
include pytest.ini
include requirements-*.txt
include tox.ini
recursive-include scrapy/templates *
recursive-include scrapy license.txt
recursive-include docs *
prune docs/build
recursive-include extras *
recursive-include bin *
recursive-include tests *
global-exclude __pycache__ *.py[cod]

View File

@ -10,7 +10,6 @@
# serve to show the default.
import sys
from datetime import datetime
from pathlib import Path
# If your extensions are in another directory, add it here. If the directory
@ -48,7 +47,7 @@ master_doc = "index"
# General information about the project.
project = "Scrapy"
copyright = f"2008{datetime.now().year}, Scrapy developers"
copyright = "Scrapy developers"
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
@ -227,7 +226,7 @@ latex_documents = [
# A list of regular expressions that match URIs that should not be checked when
# doing a linkcheck build.
linkcheck_ignore = [
"http://localhost:\d+",
r"http://localhost:\d+",
"http://hg.scrapy.org",
"http://directory.google.com/",
]

View File

@ -138,39 +138,37 @@ See previous question.
How can I prevent memory errors due to many allowed domains?
------------------------------------------------------------
If you have a spider with a long list of
:attr:`~scrapy.Spider.allowed_domains` (e.g. 50,000+), consider
replacing the default
:class:`~scrapy.spidermiddlewares.offsite.OffsiteMiddleware` spider middleware
with a :ref:`custom spider middleware <custom-spider-middleware>` that requires
less memory. For example:
If you have a spider with a long list of :attr:`~scrapy.Spider.allowed_domains`
(e.g. 50,000+), consider replacing the default
:class:`~scrapy.downloadermiddlewares.offsite.OffsiteMiddleware` downloader
middleware with a :ref:`custom downloader middleware
<topics-downloader-middleware-custom>` that requires less memory. For example:
- If your domain names are similar enough, use your own regular expression
instead joining the strings in
:attr:`~scrapy.Spider.allowed_domains` into a complex regular
expression.
instead joining the strings in :attr:`~scrapy.Spider.allowed_domains` into
a complex regular expression.
- If you can `meet the installation requirements`_, use pyre2_ instead of
Pythons re_ to compile your URL-filtering regular expression. See
:issue:`1908`.
See also other suggestions at `StackOverflow`_.
See also `other suggestions at StackOverflow
<https://stackoverflow.com/q/36440681>`__.
.. note:: Remember to disable
:class:`scrapy.spidermiddlewares.offsite.OffsiteMiddleware` when you enable
your custom implementation:
:class:`scrapy.downloadermiddlewares.offsite.OffsiteMiddleware` when you
enable your custom implementation:
.. code-block:: python
SPIDER_MIDDLEWARES = {
"scrapy.spidermiddlewares.offsite.OffsiteMiddleware": None,
"myproject.middlewares.CustomOffsiteMiddleware": 500,
DOWNLOADER_MIDDLEWARES = {
"scrapy.downloadermiddlewares.offsite.OffsiteMiddleware": None,
"myproject.middlewares.CustomOffsiteMiddleware": 50,
}
.. _meet the installation requirements: https://github.com/andreasvc/pyre2#installation
.. _pyre2: https://github.com/andreasvc/pyre2
.. _re: https://docs.python.org/library/re.html
.. _StackOverflow: https://stackoverflow.com/q/36440681/939364
Can I use Basic HTTP Authentication in my spiders?
--------------------------------------------------
@ -206,12 +204,10 @@ I get "Filtered offsite request" messages. How can I fix them?
Those messages (logged with ``DEBUG`` level) don't necessarily mean there is a
problem, so you may not need to fix them.
Those messages are thrown by the Offsite Spider Middleware, which is a spider
middleware (enabled by default) whose purpose is to filter out requests to
domains outside the ones covered by the spider.
For more info see:
:class:`~scrapy.spidermiddlewares.offsite.OffsiteMiddleware`.
Those messages are thrown by
:class:`~scrapy.downloadermiddlewares.offsite.OffsiteMiddleware`, which is a
downloader middleware (enabled by default) whose purpose is to filter out
requests to domains outside the ones covered by the spider.
What is the recommended way to deploy a Scrapy crawler in production?
---------------------------------------------------------------------
@ -409,6 +405,23 @@ or :class:`~scrapy.signals.headers_received` signals and raising a
:ref:`topics-stop-response-download` topic for additional information and examples.
.. _faq-blank-request:
How can I make a blank request?
-------------------------------
.. code-block:: python
from scrapy import Request
blank_request = Request("data:,")
In this case, the URL is set to a data URI scheme. Data URLs allow you to include data
in-line in web pages as if they were external resources. The "data:" scheme with an empty
content (",") essentially creates a request to a data URL without any specific content.
Running ``runspider`` I get ``error: No spider found in file: <filename>``
--------------------------------------------------------------------------

View File

@ -3,6 +3,117 @@
Release notes
=============
.. _release-2.11.2:
Scrapy 2.11.2 (2024-05-14)
--------------------------
Security bug fixes
~~~~~~~~~~~~~~~~~~
- Redirects to non-HTTP protocols are no longer followed. Please, see the
`23j4-mw76-5v7h security advisory`_ for more information. (:issue:`457`)
.. _23j4-mw76-5v7h security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-23j4-mw76-5v7h
- The ``Authorization`` header is now dropped on redirects to a different
scheme (``http://`` or ``https://``) or port, even if the domain is the
same. Please, see the `4qqq-9vqf-3h3f security advisory`_ for more
information.
.. _4qqq-9vqf-3h3f security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-4qqq-9vqf-3h3f
- When using system proxy settings that are different for ``http://`` and
``https://``, redirects to a different URL scheme will now also trigger the
corresponding change in proxy settings for the redirected request. Please,
see the `jm3v-qxmh-hxwv security advisory`_ for more information.
(:issue:`767`)
.. _jm3v-qxmh-hxwv security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-jm3v-qxmh-hxwv
- :attr:`Spider.allowed_domains <scrapy.Spider.allowed_domains>` is now
enforced for all requests, and not only requests from spider callbacks.
(:issue:`1042`, :issue:`2241`, :issue:`6358`)
- :func:`~scrapy.utils.iterators.xmliter_lxml` no longer resolves XML
entities. (:issue:`6265`)
- defusedxml_ is now used to make
:class:`scrapy.http.request.rpc.XmlRpcRequest` more secure.
(:issue:`6250`, :issue:`6251`)
.. _defusedxml: https://github.com/tiran/defusedxml
Bug fixes
~~~~~~~~~
- Restored support for brotlipy_, which had been dropped in Scrapy 2.11.1 in
favor of brotli_. (:issue:`6261`)
.. _brotli: https://github.com/google/brotli
.. note:: brotlipy is deprecated, both in Scrapy and upstream. Use brotli
instead if you can.
- Make :setting:`METAREFRESH_IGNORE_TAGS` ``["noscript"]`` by default. This
prevents
:class:`~scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware` from
following redirects that would not be followed by web browsers with
JavaScript enabled. (:issue:`6342`, :issue:`6347`)
- During :ref:`feed export <topics-feed-exports>`, do not close the
underlying file from :ref:`built-in post-processing plugins
<builtin-plugins>`.
(:issue:`5932`, :issue:`6178`, :issue:`6239`)
- :class:`LinkExtractor <scrapy.linkextractors.lxmlhtml.LxmlLinkExtractor>`
now properly applies the ``unique`` and ``canonicalize`` parameters.
(:issue:`3273`, :issue:`6221`)
- Do not initialize the scheduler disk queue if :setting:`JOBDIR` is an empty
string. (:issue:`6121`, :issue:`6124`)
- Fix :attr:`Spider.logger <scrapy.Spider.logger>` not logging custom extra
information. (:issue:`6323`, :issue:`6324`)
- ``robots.txt`` files with a non-UTF-8 encoding no longer prevent parsing
the UTF-8-compatible (e.g. ASCII) parts of the document.
(:issue:`6292`, :issue:`6298`)
- :meth:`scrapy.http.cookies.WrappedRequest.get_header` no longer raises an
exception if ``default`` is ``None``.
(:issue:`6308`, :issue:`6310`)
- :class:`~scrapy.selector.Selector` now uses
:func:`scrapy.utils.response.get_base_url` to determine the base URL of a
given :class:`~scrapy.http.Response`. (:issue:`6265`)
- The :meth:`media_to_download` method of :ref:`media pipelines
<topics-media-pipeline>` now logs exceptions before stripping them.
(:issue:`5067`, :issue:`5068`)
- When passing a callback to the :command:`parse` command, build the callback
callable with the right signature.
(:issue:`6182`)
Documentation
~~~~~~~~~~~~~
- Add a FAQ entry about :ref:`creating blank requests <faq-blank-request>`.
(:issue:`6203`, :issue:`6208`)
- Document that :attr:`scrapy.selector.Selector.type` can be ``"json"``.
(:issue:`6328`, :issue:`6334`)
Quality assurance
~~~~~~~~~~~~~~~~~
- Make builds reproducible. (:issue:`5019`, :issue:`6322`)
- Packaging and test fixes.
(:issue:`6286`, :issue:`6290`, :issue:`6312`, :issue:`6316`, :issue:`6344`)
.. _release-2.11.1:
Scrapy 2.11.1 (2024-02-14)

View File

@ -24,7 +24,8 @@ You should see an output like this::
'scrapy.extensions.telnet.TelnetConsole',
'scrapy.extensions.corestats.CoreStats']
2016-12-16 21:18:49 [scrapy.middleware] INFO: Enabled downloader middlewares:
['scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware',
['scrapy.downloadermiddlewares.offsite.OffsiteMiddleware',
'scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware',
'scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware',
'scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware',
'scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware',
@ -37,7 +38,6 @@ You should see an output like this::
'scrapy.downloadermiddlewares.stats.DownloaderStats']
2016-12-16 21:18:49 [scrapy.middleware] INFO: Enabled spider middlewares:
['scrapy.spidermiddlewares.httperror.HttpErrorMiddleware',
'scrapy.spidermiddlewares.offsite.OffsiteMiddleware',
'scrapy.spidermiddlewares.referer.RefererMiddleware',
'scrapy.spidermiddlewares.urllength.UrlLengthMiddleware',
'scrapy.spidermiddlewares.depth.DepthMiddleware']

View File

@ -763,6 +763,44 @@ HttpProxyMiddleware
Keep in mind this value will take precedence over ``http_proxy``/``https_proxy``
environment variables, and it will also ignore ``no_proxy`` environment variable.
OffsiteMiddleware
-----------------
.. module:: scrapy.downloadermiddlewares.offsite
:synopsis: Offsite Middleware
.. class:: OffsiteMiddleware
.. versionadded:: 2.11.2
Filters out Requests for URLs outside the domains covered by the spider.
This middleware filters out every request whose host names aren't in the
spider's :attr:`~scrapy.Spider.allowed_domains` attribute.
All subdomains of any domain in the list are also allowed.
E.g. the rule ``www.example.org`` will also allow ``bob.www.example.org``
but not ``www2.example.com`` nor ``example.com``.
When your spider returns a request for a domain not belonging to those
covered by the spider, this middleware will log a debug message similar to
this one::
DEBUG: Filtered offsite request to 'offsite.example': <GET http://offsite.example/some/page.html>
To avoid filling the log with too much noise, it will only print one of
these messages for each new domain filtered. So, for example, if another
request for ``offsite.example`` is filtered, no log message will be
printed. But if a request for ``other.example`` is filtered, a message
will be printed (but only for the first request filtered).
If the spider doesn't define an
:attr:`~scrapy.Spider.allowed_domains` attribute, or the
attribute is empty, the offsite middleware will allow all requests.
If the request has the :attr:`~scrapy.Request.dont_filter` attribute
set, the offsite middleware will allow the request even if its domain is not
listed in allowed domains.
RedirectMiddleware
------------------
@ -882,7 +920,11 @@ Meta tags within these tags are ignored.
.. versionchanged:: 2.0
The default value of :setting:`METAREFRESH_IGNORE_TAGS` changed from
``['script', 'noscript']`` to ``[]``.
``["script", "noscript"]`` to ``[]``.
.. versionchanged:: 2.11.2
The default value of :setting:`METAREFRESH_IGNORE_TAGS` changed from
``[]`` to ``["noscript"]``.
.. setting:: METAREFRESH_MAXDELAY

View File

@ -390,7 +390,13 @@ Each plugin is a class that must implement the following methods:
.. method:: close(self)
Close the target file object.
Clean up the plugin.
For example, you might want to close a file wrapper that you might have
used to compress data written into the file received in the ``__init__``
method.
.. warning:: Do not close the file from the ``__init__`` method.
To pass a parameter to your plugin, use :ref:`feed options <feed-options>`. You
can then access those parameters from the ``__init__`` method of your plugin.

View File

@ -289,7 +289,8 @@ Here are some tips to keep in mind when dealing with these kinds of sites:
services like `ProxyMesh`_. An open source alternative is `scrapoxy`_, a
super proxy that you can attach your own proxies to.
* use a ban avoidance service, such as `Zyte API`_, which provides a `Scrapy
plugin <https://github.com/scrapy-plugins/scrapy-zyte-api>`__
plugin <https://github.com/scrapy-plugins/scrapy-zyte-api>`__ and additional
features, like `AI web scraping <https://www.zyte.com/ai-web-scraping/>`__
If you are still unable to prevent your bot getting banned, consider contacting
`commercial support`_.

View File

@ -674,6 +674,7 @@ Default:
.. code-block:: python
{
"scrapy.downloadermiddlewares.offsite.OffsiteMiddleware": 50,
"scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware": 100,
"scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware": 300,
"scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware": 350,
@ -1122,7 +1123,7 @@ modify this setting in your project, modify :setting:`ITEM_PIPELINES` instead.
JOBDIR
------
Default: ``''``
Default: ``None``
A string indicating the directory for storing the state of a crawl when
:ref:`pausing and resuming crawls <topics-jobs>`.
@ -1605,7 +1606,6 @@ Default:
{
"scrapy.spidermiddlewares.httperror.HttpErrorMiddleware": 50,
"scrapy.spidermiddlewares.offsite.OffsiteMiddleware": 500,
"scrapy.spidermiddlewares.referer.RefererMiddleware": 700,
"scrapy.spidermiddlewares.urllength.UrlLengthMiddleware": 800,
"scrapy.spidermiddlewares.depth.DepthMiddleware": 900,

View File

@ -343,11 +343,18 @@ request_scheduled
.. signal:: request_scheduled
.. function:: request_scheduled(request, spider)
Sent when the engine schedules a :class:`~scrapy.Request`, to be
downloaded later.
Sent when the engine is asked to schedule a :class:`~scrapy.Request`, to be
downloaded later, before the request reaches the :ref:`scheduler
<topics-scheduler>`.
Raise :exc:`~scrapy.exceptions.IgnoreRequest` to drop a request before it
reaches the scheduler.
This signal does not support returning deferreds from its handlers.
.. versionadded:: 2.11.2
Allow dropping requests with :exc:`~scrapy.exceptions.IgnoreRequest`.
:param request: the request that reached the scheduler
:type request: :class:`~scrapy.Request` object

View File

@ -51,8 +51,8 @@ value. For example, if you want to disable the off-site middleware:
.. code-block:: python
SPIDER_MIDDLEWARES = {
"myproject.middlewares.CustomSpiderMiddleware": 543,
"scrapy.spidermiddlewares.offsite.OffsiteMiddleware": None,
"scrapy.spidermiddlewares.referer.RefererMiddleware": None,
"myproject.middlewares.CustomRefererSpiderMiddleware": 700,
}
Finally, keep in mind that some middlewares may need to be enabled through a
@ -313,42 +313,6 @@ Default: ``False``
Pass all responses, regardless of its status code.
OffsiteMiddleware
-----------------
.. module:: scrapy.spidermiddlewares.offsite
:synopsis: Offsite Spider Middleware
.. class:: OffsiteMiddleware
Filters out Requests for URLs outside the domains covered by the spider.
This middleware filters out every request whose host names aren't in the
spider's :attr:`~scrapy.Spider.allowed_domains` attribute.
All subdomains of any domain in the list are also allowed.
E.g. the rule ``www.example.org`` will also allow ``bob.www.example.org``
but not ``www2.example.com`` nor ``example.com``.
When your spider returns a request for a domain not belonging to those
covered by the spider, this middleware will log a debug message similar to
this one::
DEBUG: Filtered offsite request to 'www.othersite.com': <GET http://www.othersite.com/some/page.html>
To avoid filling the log with too much noise, it will only print one of
these messages for each new domain filtered. So, for example, if another
request for ``www.othersite.com`` is filtered, no log message will be
printed. But if a request for ``someothersite.com`` is filtered, a message
will be printed (but only for the first request filtered).
If the spider doesn't define an
:attr:`~scrapy.Spider.allowed_domains` attribute, or the
attribute is empty, the offsite middleware will allow all requests.
If the request has the :attr:`~scrapy.Request.dont_filter` attribute
set, the offsite middleware will allow the request even if its domain is not
listed in allowed domains.
RefererMiddleware
-----------------

View File

@ -75,7 +75,8 @@ scrapy.Spider
An optional list of strings containing domains that this spider is
allowed to crawl. Requests for URLs not belonging to the domain names
specified in this list (or their subdomains) won't be followed if
:class:`~scrapy.spidermiddlewares.offsite.OffsiteMiddleware` is enabled.
:class:`~scrapy.downloadermiddlewares.offsite.OffsiteMiddleware` is
enabled.
Let's say your target url is ``https://www.example.com/1.html``,
then add ``'example.com'`` to the list.

View File

@ -1 +1 @@
2.11.1
2.11.2

View File

@ -1,3 +1,4 @@
import functools
import inspect
import json
import logging
@ -251,39 +252,40 @@ class Command(BaseRunSpiderCommand):
return scraped_data
def _get_callback(self, *, spider, opts, response=None):
cb = None
if response:
cb = response.meta["_callback"]
if not cb:
if opts.callback:
cb = opts.callback
elif response and opts.rules and self.first_response == response:
cb = self.get_callback_from_rules(spider, response)
if not cb:
raise ValueError(
f"Cannot find a rule that matches {response.url!r} in spider: "
f"{spider.name}"
)
else:
cb = "parse"
if not callable(cb):
cb_method = getattr(spider, cb, None)
if callable(cb_method):
cb = cb_method
else:
raise ValueError(
f"Cannot find callback {cb!r} in spider: {spider.name}"
)
return cb
def prepare_request(self, spider, request, opts):
def callback(response, **cb_kwargs):
# memorize first request
if not self.first_response:
self.first_response = response
# determine real callback
cb = response.meta["_callback"]
if not cb:
if opts.callback:
cb = opts.callback
elif opts.rules and self.first_response == response:
cb = self.get_callback_from_rules(spider, response)
if not cb:
logger.error(
"Cannot find a rule that matches %(url)r in spider: %(spider)s",
{"url": response.url, "spider": spider.name},
)
return
else:
cb = "parse"
if not callable(cb):
cb_method = getattr(spider, cb, None)
if callable(cb_method):
cb = cb_method
else:
logger.error(
"Cannot find callback %(callback)r in spider: %(spider)s",
{"callback": cb, "spider": spider.name},
)
return
cb = self._get_callback(spider=spider, opts=opts, response=response)
# parse items and requests
depth = response.meta["_depth"]
@ -303,6 +305,9 @@ class Command(BaseRunSpiderCommand):
request.meta["_depth"] = 1
request.meta["_callback"] = request.callback
if not request.callback and not opts.rules:
cb = self._get_callback(spider=spider, opts=opts)
functools.update_wrapper(callback, cb)
request.callback = callback
return request

View File

@ -27,7 +27,7 @@ from twisted.python.failure import Failure
from scrapy import signals
from scrapy.core.downloader import Downloader
from scrapy.core.scraper import Scraper
from scrapy.exceptions import CloseSpider, DontCloseSpider
from scrapy.exceptions import CloseSpider, DontCloseSpider, IgnoreRequest
from scrapy.http import Request, Response
from scrapy.logformatter import LogFormatter
from scrapy.settings import BaseSettings, Settings
@ -35,6 +35,7 @@ from scrapy.signalmanager import SignalManager
from scrapy.spiders import Spider
from scrapy.utils.log import failure_to_exc_info, logformatter_adapter
from scrapy.utils.misc import create_instance, load_object
from scrapy.utils.python import global_object_name
from scrapy.utils.reactor import CallLaterOnce
if TYPE_CHECKING:
@ -291,9 +292,19 @@ class ExecutionEngine:
self.slot.nextcall.schedule() # type: ignore[union-attr]
def _schedule_request(self, request: Request, spider: Spider) -> None:
self.signals.send_catch_log(
signals.request_scheduled, request=request, spider=spider
request_scheduled_result = self.signals.send_catch_log(
signals.request_scheduled,
request=request,
spider=spider,
dont_log=IgnoreRequest,
)
for handler, result in request_scheduled_result:
if isinstance(result, Failure) and isinstance(result.value, IgnoreRequest):
logger.debug(
f"Signal handler {global_object_name(handler)} dropped "
f"request {request} before it reached the scheduler."
)
return
if not self.slot.scheduler.enqueue_request(request): # type: ignore[union-attr]
self.signals.send_catch_log(
signals.request_dropped, request=request, spider=spider

View File

@ -352,7 +352,7 @@ class Scheduler(BaseScheduler):
def _dqdir(self, jobdir: Optional[str]) -> Optional[str]:
"""Return a folder name to keep disk queue state at"""
if jobdir is not None:
if jobdir:
dqdir = Path(jobdir, "requests.queue")
if not dqdir.exists():
dqdir.mkdir(parents=True)

View File

@ -44,23 +44,26 @@ class HttpProxyMiddleware:
return creds, proxy_url
def process_request(self, request, spider):
creds, proxy_url = None, None
creds, proxy_url, scheme = None, None, None
if "proxy" in request.meta:
if request.meta["proxy"] is not None:
creds, proxy_url = self._get_proxy(request.meta["proxy"], "")
elif self.proxies:
parsed = urlparse_cached(request)
scheme = parsed.scheme
_scheme = parsed.scheme
if (
# 'no_proxy' is only supported by http schemes
scheme not in ("http", "https")
_scheme not in ("http", "https")
or not proxy_bypass(parsed.hostname)
) and scheme in self.proxies:
) and _scheme in self.proxies:
scheme = _scheme
creds, proxy_url = self.proxies[scheme]
self._set_proxy_and_creds(request, proxy_url, creds)
self._set_proxy_and_creds(request, proxy_url, creds, scheme)
def _set_proxy_and_creds(self, request, proxy_url, creds):
def _set_proxy_and_creds(self, request, proxy_url, creds, scheme):
if scheme:
request.meta["_scheme_proxy"] = True
if proxy_url:
request.meta["proxy"] = proxy_url
elif request.meta.get("proxy") is not None:

View File

@ -0,0 +1,77 @@
import logging
import re
import warnings
from scrapy import signals
from scrapy.exceptions import IgnoreRequest
from scrapy.utils.httpobj import urlparse_cached
logger = logging.getLogger(__name__)
class OffsiteMiddleware:
@classmethod
def from_crawler(cls, crawler):
o = cls(crawler.stats)
crawler.signals.connect(o.spider_opened, signal=signals.spider_opened)
crawler.signals.connect(o.request_scheduled, signal=signals.request_scheduled)
return o
def __init__(self, stats):
self.stats = stats
self.domains_seen = set()
def spider_opened(self, spider):
self.host_regex = self.get_host_regex(spider)
def request_scheduled(self, request, spider):
self.process_request(request, spider)
def process_request(self, request, spider):
if request.dont_filter or self.should_follow(request, spider):
return None
domain = urlparse_cached(request).hostname
if domain and domain not in self.domains_seen:
self.domains_seen.add(domain)
logger.debug(
"Filtered offsite request to %(domain)r: %(request)s",
{"domain": domain, "request": request},
extra={"spider": spider},
)
self.stats.inc_value("offsite/domains", spider=spider)
self.stats.inc_value("offsite/filtered", spider=spider)
raise IgnoreRequest
def should_follow(self, request, spider):
regex = self.host_regex
# hostname can be None for wrong urls (like javascript links)
host = urlparse_cached(request).hostname or ""
return bool(regex.search(host))
def get_host_regex(self, spider):
"""Override this method to implement a different offsite policy"""
allowed_domains = getattr(spider, "allowed_domains", None)
if not allowed_domains:
return re.compile("") # allow all by default
url_pattern = re.compile(r"^https?://.*$")
port_pattern = re.compile(r":\d+$")
domains = []
for domain in allowed_domains:
if domain is None:
continue
if url_pattern.match(domain):
message = (
"allowed_domains accepts only domains, not URLs. "
f"Ignoring URL entry {domain} in allowed_domains."
)
warnings.warn(message)
elif port_pattern.search(domain):
message = (
"allowed_domains accepts only domains without ports. "
f"Ignoring entry {domain} in allowed_domains."
)
warnings.warn(message)
else:
domains.append(re.escape(domain))
regex = rf'^(.*\.)?({"|".join(domains)})$'
return re.compile(regex)

View File

@ -17,17 +17,49 @@ def _build_redirect_request(source_request, *, url, **kwargs):
**kwargs,
cookies=None,
)
if "_scheme_proxy" in redirect_request.meta:
source_request_scheme = urlparse_cached(source_request).scheme
redirect_request_scheme = urlparse_cached(redirect_request).scheme
if source_request_scheme != redirect_request_scheme:
redirect_request.meta.pop("_scheme_proxy")
redirect_request.meta.pop("proxy", None)
redirect_request.meta.pop("_auth_proxy", None)
redirect_request.headers.pop(b"Proxy-Authorization", None)
has_cookie_header = "Cookie" in redirect_request.headers
has_authorization_header = "Authorization" in redirect_request.headers
if has_cookie_header or has_authorization_header:
source_request_netloc = urlparse_cached(source_request).netloc
redirect_request_netloc = urlparse_cached(redirect_request).netloc
if source_request_netloc != redirect_request_netloc:
if has_cookie_header:
del redirect_request.headers["Cookie"]
# https://fetch.spec.whatwg.org/#ref-for-cors-non-wildcard-request-header-name
if has_authorization_header:
del redirect_request.headers["Authorization"]
default_ports = {"http": 80, "https": 443}
parsed_source_request = urlparse_cached(source_request)
source_scheme, source_host, source_port = (
parsed_source_request.scheme,
parsed_source_request.hostname,
parsed_source_request.port
or default_ports.get(parsed_source_request.scheme),
)
parsed_redirect_request = urlparse_cached(redirect_request)
redirect_scheme, redirect_host, redirect_port = (
parsed_redirect_request.scheme,
parsed_redirect_request.hostname,
parsed_redirect_request.port
or default_ports.get(parsed_redirect_request.scheme),
)
if has_cookie_header and (
(source_scheme != redirect_scheme and redirect_scheme != "https")
or source_host != redirect_host
):
del redirect_request.headers["Cookie"]
# https://fetch.spec.whatwg.org/#ref-for-cors-non-wildcard-request-header-name
if has_authorization_header and (
source_scheme != redirect_scheme
or source_host != redirect_host
or source_port != redirect_port
):
del redirect_request.headers["Authorization"]
return redirect_request
@ -110,6 +142,8 @@ class RedirectMiddleware(BaseRedirectMiddleware):
location = request_scheme + "://" + location.lstrip("/")
redirected_url = urljoin(request.url, location)
if urlparse(redirected_url).scheme not in {"http", "https"}:
return response
if response.status in (301, 307, 308) or request.method == "HEAD":
redirected = _build_redirect_request(request, url=redirected_url)
@ -132,12 +166,16 @@ class MetaRefreshMiddleware(BaseRedirectMiddleware):
request.meta.get("dont_redirect", False)
or request.method == "HEAD"
or not isinstance(response, HtmlResponse)
or urlparse_cached(request).scheme not in {"http", "https"}
):
return response
interval, url = get_meta_refresh(response, ignore_tags=self._ignore_tags)
if url and interval < self._maxdelay:
if not url:
return response
if urlparse(url).scheme not in {"http", "https"}:
return response
if interval < self._maxdelay:
redirected = self._redirect_request_using_get(request, url)
return self._redirect(redirected, request, spider, "meta refresh")
return response

View File

@ -128,9 +128,9 @@ class MemoryUsage:
def _send_report(self, rcpts, subject):
"""send notification mail with some additional useful info"""
stats = self.crawler.stats
s = f"Memory usage at engine startup : {stats.get_value('memusage/startup')/1024/1024}M\r\n"
s += f"Maximum memory usage : {stats.get_value('memusage/max')/1024/1024}M\r\n"
s += f"Current memory usage : {self.get_virtual_size()/1024/1024}M\r\n"
s = f"Memory usage at engine startup : {stats.get_value('memusage/startup') / 1024 / 1024}M\r\n"
s += f"Maximum memory usage : {stats.get_value('memusage/max') / 1024 / 1024}M\r\n"
s += f"Current memory usage : {self.get_virtual_size() / 1024 / 1024}M\r\n"
s += (
"ENGINE STATUS ------------------------------------------------------- \r\n"

View File

@ -42,7 +42,6 @@ class GzipPlugin:
def close(self) -> None:
self.gzipfile.close()
self.file.close()
class Bz2Plugin:
@ -69,7 +68,6 @@ class Bz2Plugin:
def close(self) -> None:
self.bz2file.close()
self.file.close()
class LZMAPlugin:
@ -111,7 +109,6 @@ class LZMAPlugin:
def close(self) -> None:
self.lzmafile.close()
self.file.close()
# io.IOBase is subclassed here, so that exporters can use the PostProcessingManager

View File

@ -166,7 +166,8 @@ class WrappedRequest:
return name in self.request.headers
def get_header(self, name, default=None):
return to_unicode(self.request.headers.get(name, default), errors="replace")
value = self.request.headers.get(name, default)
return to_unicode(value, errors="replace") if value is not None else None
def header_items(self):
return [

View File

@ -8,21 +8,16 @@ See documentation in docs/topics/request-response.rst
from typing import Iterable, List, Optional, Tuple, Type, TypeVar, Union, cast
from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit
from lxml.html import (
FormElement,
HTMLParser,
InputElement,
MultipleSelectOptions,
SelectElement,
TextareaElement,
)
from parsel.selector import create_root_node
from lxml.html import FormElement # nosec
from lxml.html import InputElement # nosec
from lxml.html import MultipleSelectOptions # nosec
from lxml.html import SelectElement # nosec
from lxml.html import TextareaElement # nosec
from w3lib.html import strip_html5_whitespace
from scrapy.http.request import Request
from scrapy.http.response.text import TextResponse
from scrapy.utils.python import is_listlike, to_bytes
from scrapy.utils.response import get_base_url
FormRequestTypeVar = TypeVar("FormRequestTypeVar", bound="FormRequest")
@ -113,7 +108,7 @@ def _get_form(
formxpath: Optional[str],
) -> FormElement:
"""Find the wanted form element within the given response."""
root = create_root_node(response.text, HTMLParser, base_url=get_base_url(response))
root = response.selector.root
forms = root.xpath("//form")
if not forms:
raise ValueError(f"No <form> element found in {response}")

View File

@ -7,9 +7,13 @@ See documentation in docs/topics/request-response.rst
import xmlrpc.client as xmlrpclib
from typing import Optional
import defusedxml.xmlrpc
from scrapy.http.request import Request
from scrapy.utils.python import get_func_args
defusedxml.xmlrpc.monkey_patch()
DUMPS_ARGS = get_func_args(xmlrpclib.dumps)

View File

@ -6,7 +6,7 @@ import operator
from functools import partial
from urllib.parse import urljoin, urlparse
from lxml import etree
from lxml import etree # nosec
from parsel.csstranslator import HTMLTranslator
from w3lib.html import strip_html5_whitespace
from w3lib.url import canonicalize_url, safe_url_string
@ -153,7 +153,7 @@ class LxmlLinkExtractor:
unique=unique,
process=process_value,
strip=strip,
canonicalized=canonicalize,
canonicalized=not canonicalize,
)
self.allow_res = [
x if isinstance(x, _re_type) else re.compile(x) for x in arg_to_iter(allow)
@ -248,5 +248,5 @@ class LxmlLinkExtractor:
links = self._extract_links(doc, response.url, response.encoding, base_url)
all_links.extend(self._process_links(links))
if self.link_extractor.unique:
return unique_list(all_links)
return unique_list(all_links, key=self.link_extractor.link_key)
return all_links

View File

@ -118,14 +118,14 @@ class MediaPipeline:
info.downloading.add(fp)
dfd = mustbe_deferred(self.media_to_download, request, info, item=item)
dfd.addCallback(self._check_media_to_download, request, info, item=item)
dfd.addErrback(self._log_exception)
dfd.addBoth(self._cache_result_and_execute_waiters, fp, info)
dfd.addErrback(
lambda f: logger.error(
f.value, exc_info=failure_to_exc_info(f), extra={"spider": info.spider}
)
)
return dfd.addBoth(lambda _: wad) # it must return wad at last
def _log_exception(self, result):
logger.exception(result)
return result
def _make_compatible(self):
"""Make overridable methods of MediaPipeline and subclasses backwards compatible"""
methods = [

View File

@ -12,7 +12,7 @@ def decode_robotstxt(robotstxt_body, spider, to_native_str_type=False):
if to_native_str_type:
robotstxt_body = to_unicode(robotstxt_body)
else:
robotstxt_body = robotstxt_body.decode("utf-8")
robotstxt_body = robotstxt_body.decode("utf-8", errors="ignore")
except UnicodeDecodeError:
# If we found garbage or robots.txt in an encoding other than UTF-8, disregard it.
# Switch to 'allow all' state.

View File

@ -7,6 +7,7 @@ from parsel import Selector as _ParselSelector
from scrapy.http import HtmlResponse, TextResponse, XmlResponse
from scrapy.utils.python import to_bytes
from scrapy.utils.response import get_base_url
from scrapy.utils.trackref import object_ref
__all__ = ["Selector", "SelectorList"]
@ -45,7 +46,7 @@ class Selector(_ParselSelector, object_ref):
``response`` isn't available. Using ``text`` and ``response`` together is
undefined behavior.
``type`` defines the selector type, it can be ``"html"``, ``"xml"``
``type`` defines the selector type, it can be ``"html"``, ``"xml"``, ``"json"``
or ``None`` (default).
If ``type`` is ``None``, the selector automatically chooses the best type
@ -87,7 +88,7 @@ class Selector(_ParselSelector, object_ref):
if response is not None:
text = response.text
kwargs.setdefault("base_url", response.url)
kwargs.setdefault("base_url", get_base_url(response))
self.response = response

View File

@ -101,6 +101,7 @@ DOWNLOADER_MIDDLEWARES = {}
DOWNLOADER_MIDDLEWARES_BASE = {
# Engine side
"scrapy.downloadermiddlewares.offsite.OffsiteMiddleware": 50,
"scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware": 100,
"scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware": 300,
"scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware": 350,
@ -206,6 +207,8 @@ ITEM_PROCESSOR = "scrapy.pipelines.ItemPipelineManager"
ITEM_PIPELINES = {}
ITEM_PIPELINES_BASE = {}
JOBDIR = None
LOG_ENABLED = True
LOG_ENCODING = "utf-8"
LOG_FORMATTER = "scrapy.logformatter.LogFormatter"
@ -237,7 +240,7 @@ MEMUSAGE_NOTIFY_MAIL = []
MEMUSAGE_WARNING_MB = 0
METAREFRESH_ENABLED = True
METAREFRESH_IGNORE_TAGS = []
METAREFRESH_IGNORE_TAGS = ["noscript"]
METAREFRESH_MAXDELAY = 100
NEWSPIDER_MODULE = ""
@ -299,7 +302,6 @@ SPIDER_MIDDLEWARES = {}
SPIDER_MIDDLEWARES_BASE = {
# Engine side
"scrapy.spidermiddlewares.httperror.HttpErrorMiddleware": 50,
"scrapy.spidermiddlewares.offsite.OffsiteMiddleware": 500,
"scrapy.spidermiddlewares.referer.RefererMiddleware": 700,
"scrapy.spidermiddlewares.urllength.UrlLengthMiddleware": 800,
"scrapy.spidermiddlewares.depth.DepthMiddleware": 900,

View File

@ -8,9 +8,16 @@ import re
import warnings
from scrapy import signals
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.http import Request
from scrapy.utils.httpobj import urlparse_cached
warnings.warn(
"The scrapy.spidermiddlewares.offsite module is deprecated, use "
"scrapy.downloadermiddlewares.offsite instead.",
ScrapyDeprecationWarning,
)
logger = logging.getLogger(__name__)

View File

@ -21,6 +21,7 @@ if TYPE_CHECKING:
from scrapy.crawler import Crawler
from scrapy.settings import BaseSettings
from scrapy.utils.log import SpiderLoggerAdapter
class Spider(object_ref):
@ -41,9 +42,11 @@ class Spider(object_ref):
self.start_urls: List[str] = []
@property
def logger(self) -> logging.LoggerAdapter:
def logger(self) -> SpiderLoggerAdapter:
from scrapy.utils.log import SpiderLoggerAdapter
logger = logging.getLogger(self.name)
return logging.LoggerAdapter(logger, {"spider": self})
return SpiderLoggerAdapter(logger, {"spider": self})
def log(self, message: Any, level: int = logging.DEBUG, **kw: Any) -> None:
"""Log the given message at the given log level

View File

@ -1,10 +1,39 @@
import zlib
from io import BytesIO
from warnings import warn
from scrapy.exceptions import ScrapyDeprecationWarning
try:
import brotli
except ImportError:
pass
else:
try:
brotli.Decompressor.process
except AttributeError:
warn(
(
"You have brotlipy installed, and Scrapy will use it, but "
"Scrapy support for brotlipy is deprecated and will stop "
"working in a future version of Scrapy. brotlipy itself is "
"deprecated, it has been superseded by brotlicffi (not "
"currently supported by Scrapy). Please, uninstall brotlipy "
"and install brotli instead. brotlipy has the same import "
"name as brotli, so keeping both installed is strongly "
"discouraged."
),
ScrapyDeprecationWarning,
)
def _brotli_decompress(decompressor, data):
return decompressor.decompress(data)
else:
def _brotli_decompress(decompressor, data):
return decompressor.process(data)
try:
import zstandard
@ -61,7 +90,7 @@ def _unbrotli(data: bytes, *, max_size: int = 0) -> bytes:
decompressed_size = 0
while output_chunk:
input_chunk = input_stream.read(_CHUNK_SIZE)
output_chunk = decompressor.process(input_chunk)
output_chunk = _brotli_decompress(decompressor, input_chunk)
decompressed_size += len(output_chunk)
if max_size and decompressed_size > max_size:
raise _DecompressionMaxSizeExceeded(

View File

@ -18,7 +18,7 @@ from typing import (
)
from warnings import warn
from lxml import etree
from lxml import etree # nosec
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.http import Response, TextResponse
@ -26,7 +26,7 @@ from scrapy.selector import Selector
from scrapy.utils.python import re_rsearch, to_unicode
if TYPE_CHECKING:
from lxml._types import SupportsReadClose
from lxml._types import SupportsReadClose # nosec
logger = logging.getLogger(__name__)
@ -101,6 +101,7 @@ def xmliter_lxml(
cast("SupportsReadClose[bytes]", reader),
encoding=reader.encoding,
events=("end", "start-ns"),
resolve_entities=False,
huge_tree=True,
)
selxpath = "//" + (f"{prefix}:{nodename}" if namespace else nodename)

View File

@ -5,7 +5,9 @@ from scrapy.settings import BaseSettings
def job_dir(settings: BaseSettings) -> Optional[str]:
path: str = settings["JOBDIR"]
if path and not Path(path).exists():
path: Optional[str] = settings["JOBDIR"]
if not path:
return None
if not Path(path).exists():
Path(path).mkdir(parents=True)
return path

View File

@ -5,7 +5,17 @@ import sys
import warnings
from logging.config import dictConfig
from types import TracebackType
from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Type, Union, cast
from typing import (
TYPE_CHECKING,
Any,
List,
MutableMapping,
Optional,
Tuple,
Type,
Union,
cast,
)
from twisted.python import log as twisted_log
from twisted.python.failure import Failure
@ -249,3 +259,16 @@ def logformatter_adapter(logkws: dict) -> Tuple[int, str, dict]:
args = logkws if not logkws.get("args") else logkws["args"]
return (level, message, args)
class SpiderLoggerAdapter(logging.LoggerAdapter):
def process(
self, msg: str, kwargs: MutableMapping[str, Any]
) -> Tuple[str, MutableMapping[str, Any]]:
"""Method that augments logging with additional 'extra' data"""
if isinstance(kwargs.get("extra"), MutableMapping):
kwargs["extra"].update(self.extra)
else:
kwargs["extra"] = self.extra
return msg, kwargs

View File

@ -316,7 +316,7 @@ def global_object_name(obj: Any) -> str:
>>> global_object_name(Request)
'scrapy.http.request.Request'
"""
return f"{obj.__module__}.{obj.__name__}"
return f"{obj.__module__}.{obj.__qualname__}"
if hasattr(sys, "pypy_version_info"):

View File

@ -7,7 +7,7 @@ SitemapSpider, its API is subject to change without notice.
from typing import Any, Dict, Generator, Iterator, Optional
from urllib.parse import urljoin
import lxml.etree
import lxml.etree # nosec
class Sitemap:
@ -18,7 +18,7 @@ class Sitemap:
xmlp = lxml.etree.XMLParser(
recover=True, remove_comments=True, resolve_entities=False
)
self._root = lxml.etree.fromstring(xmltext, parser=xmlp)
self._root = lxml.etree.fromstring(xmltext, parser=xmlp) # nosec
rt = self._root.tag
self.type = self._root.tag.split("}", 1)[1] if "}" in rt else rt

View File

@ -4,7 +4,7 @@ from typing import List, Tuple
import cryptography
import cssselect
import lxml.etree
import lxml.etree # nosec
import parsel
import twisted
import w3lib

View File

@ -22,6 +22,7 @@ install_requires = [
"packaging",
"tldextract",
"lxml>=4.4.1",
"defusedxml>=0.7.1",
]
extras_require = {
':platform_python_implementation == "CPython"': ["PyDispatcher>=2.0.5"],

View File

@ -3,6 +3,7 @@ Some spiders used for testing and benchmarking
"""
import asyncio
import time
from typing import Optional
from urllib.parse import urlencode
from twisted.internet import defer
@ -77,6 +78,28 @@ class DelaySpider(MetaSpider):
self.t2_err = time.time()
class LogSpider(MetaSpider):
name = "log_spider"
def log_debug(self, message: str, extra: Optional[dict] = None):
self.logger.debug(message, extra=extra)
def log_info(self, message: str, extra: Optional[dict] = None):
self.logger.info(message, extra=extra)
def log_warning(self, message: str, extra: Optional[dict] = None):
self.logger.warning(message, extra=extra)
def log_error(self, message: str, extra: Optional[dict] = None):
self.logger.error(message, extra=extra)
def log_critical(self, message: str, extra: Optional[dict] = None):
self.logger.critical(message, extra=extra)
def parse(self, response):
pass
class SlowSpider(DelaySpider):
name = "slow"

View File

@ -16,11 +16,11 @@ import scrapy
class CheckSpider(scrapy.Spider):
name = '{self.spider_name}'
start_urls = ['http://toscrape.com']
start_urls = ['data:,']
def parse(self, response, **cb_kwargs):
\"\"\"
@url http://toscrape.com
@url data:,
{contracts}
\"\"\"
{parse_def}

View File

@ -78,9 +78,21 @@ class AsyncDefAsyncioGenExcSpider(scrapy.Spider):
if i > 5:
raise ValueError("Stopping the processing")
class CallbackSignatureDownloaderMiddleware:
def process_request(self, request, spider):
from inspect import signature
spider.logger.debug(f"request.callback signature: {{signature(request.callback)}}")
class MySpider(scrapy.Spider):
name = '{self.spider_name}'
custom_settings = {{
"DOWNLOADER_MIDDLEWARES": {{
CallbackSignatureDownloaderMiddleware: 0,
}}
}}
def parse(self, response):
if getattr(self, 'test_arg', None):
self.logger.debug('It Works!')
@ -220,7 +232,11 @@ ITEM_PIPELINES = {{'{self.project_name}.pipelines.MyPipeline': 1}}
self.url("/html"),
]
)
self.assertIn("DEBUG: It Works!", _textmode(stderr))
log = _textmode(stderr)
self.assertIn("DEBUG: It Works!", log)
self.assertIn(
"DEBUG: request.callback signature: (response, foo=None, key=None)", log
)
@defer.inlineCallbacks
def test_request_without_meta(self):

View File

@ -988,12 +988,13 @@ class MySpider(scrapy.Spider):
self.assertIn("The value of FOO is 42", log)
@skipIf(platform.system() != "Windows", "Windows required for .pyw files")
class WindowsRunSpiderCommandTest(RunSpiderCommandTest):
spider_filename = "myspider.pyw"
def setUp(self):
super().setUp()
if platform.system() != "Windows":
raise unittest.SkipTest("Windows required for .pyw files")
return super().setUp()
def test_start_requests_errors(self):
log = self.get_log(self.badspider, name="badspider.pyw")

View File

@ -22,13 +22,11 @@ class ManagerTestCase(TestCase):
self.crawler = get_crawler(Spider, self.settings_dict)
self.spider = self.crawler._create_spider("foo")
self.mwman = DownloaderMiddlewareManager.from_crawler(self.crawler)
# some mw depends on stats collector
self.crawler.stats.open_spider(self.spider)
return self.mwman.open_spider(self.spider)
self.crawler.engine = self.crawler._create_engine()
return self.crawler.engine.open_spider(self.spider, start_requests=())
def tearDown(self):
self.crawler.stats.close_spider(self.spider, "")
return self.mwman.close_spider(self.spider)
return self.crawler.engine.close_spider(self.spider)
def _download(self, request, response=None):
"""Executes downloader mw manager's download method and returns

View File

@ -0,0 +1,184 @@
import pytest
from scrapy import Request, Spider
from scrapy.downloadermiddlewares.offsite import OffsiteMiddleware
from scrapy.exceptions import IgnoreRequest
from scrapy.utils.test import get_crawler
UNSET = object()
@pytest.mark.parametrize(
("allowed_domain", "url", "allowed"),
(
("example.com", "http://example.com/1", True),
("example.com", "http://example.org/1", False),
("example.com", "http://sub.example.com/1", True),
("sub.example.com", "http://sub.example.com/1", True),
("sub.example.com", "http://example.com/1", False),
("example.com", "http://example.com:8000/1", True),
("example.com", "http://example.org/example.com", False),
("example.com", "http://example.org/foo.example.com", False),
("example.com", "http://example.com.example", False),
("a.example", "http://nota.example", False),
("b.a.example", "http://notb.a.example", False),
),
)
def test_process_request_domain_filtering(allowed_domain, url, allowed):
crawler = get_crawler(Spider)
spider = crawler._create_spider(name="a", allowed_domains=[allowed_domain])
mw = OffsiteMiddleware.from_crawler(crawler)
mw.spider_opened(spider)
request = Request(url)
if allowed:
assert mw.process_request(request, spider) is None
else:
with pytest.raises(IgnoreRequest):
mw.process_request(request, spider)
@pytest.mark.parametrize(
("value", "filtered"),
(
(UNSET, True),
(None, True),
(False, True),
(True, False),
),
)
def test_process_request_dont_filter(value, filtered):
crawler = get_crawler(Spider)
spider = crawler._create_spider(name="a", allowed_domains=["a.example"])
mw = OffsiteMiddleware.from_crawler(crawler)
mw.spider_opened(spider)
kwargs = {}
if value is not UNSET:
kwargs["dont_filter"] = value
request = Request("https://b.example", **kwargs)
if filtered:
with pytest.raises(IgnoreRequest):
mw.process_request(request, spider)
else:
assert mw.process_request(request, spider) is None
@pytest.mark.parametrize(
"value",
(
UNSET,
None,
[],
),
)
def test_process_request_no_allowed_domains(value):
crawler = get_crawler(Spider)
kwargs = {}
if value is not UNSET:
kwargs["allowed_domains"] = value
spider = crawler._create_spider(name="a", **kwargs)
mw = OffsiteMiddleware.from_crawler(crawler)
mw.spider_opened(spider)
request = Request("https://example.com")
assert mw.process_request(request, spider) is None
def test_process_request_invalid_domains():
crawler = get_crawler(Spider)
allowed_domains = ["a.example", None, "http:////b.example", "//c.example"]
spider = crawler._create_spider(name="a", allowed_domains=allowed_domains)
mw = OffsiteMiddleware.from_crawler(crawler)
mw.spider_opened(spider)
request = Request("https://a.example")
assert mw.process_request(request, spider) is None
for letter in ("b", "c"):
request = Request(f"https://{letter}.example")
with pytest.raises(IgnoreRequest):
mw.process_request(request, spider)
@pytest.mark.parametrize(
("allowed_domain", "url", "allowed"),
(
("example.com", "http://example.com/1", True),
("example.com", "http://example.org/1", False),
("example.com", "http://sub.example.com/1", True),
("sub.example.com", "http://sub.example.com/1", True),
("sub.example.com", "http://example.com/1", False),
("example.com", "http://example.com:8000/1", True),
("example.com", "http://example.org/example.com", False),
("example.com", "http://example.org/foo.example.com", False),
("example.com", "http://example.com.example", False),
("a.example", "http://nota.example", False),
("b.a.example", "http://notb.a.example", False),
),
)
def test_request_scheduled_domain_filtering(allowed_domain, url, allowed):
crawler = get_crawler(Spider)
spider = crawler._create_spider(name="a", allowed_domains=[allowed_domain])
mw = OffsiteMiddleware.from_crawler(crawler)
mw.spider_opened(spider)
request = Request(url)
if allowed:
assert mw.request_scheduled(request, spider) is None
else:
with pytest.raises(IgnoreRequest):
mw.request_scheduled(request, spider)
@pytest.mark.parametrize(
("value", "filtered"),
(
(UNSET, True),
(None, True),
(False, True),
(True, False),
),
)
def test_request_scheduled_dont_filter(value, filtered):
crawler = get_crawler(Spider)
spider = crawler._create_spider(name="a", allowed_domains=["a.example"])
mw = OffsiteMiddleware.from_crawler(crawler)
mw.spider_opened(spider)
kwargs = {}
if value is not UNSET:
kwargs["dont_filter"] = value
request = Request("https://b.example", **kwargs)
if filtered:
with pytest.raises(IgnoreRequest):
mw.request_scheduled(request, spider)
else:
assert mw.request_scheduled(request, spider) is None
@pytest.mark.parametrize(
"value",
(
UNSET,
None,
[],
),
)
def test_request_scheduled_no_allowed_domains(value):
crawler = get_crawler(Spider)
kwargs = {}
if value is not UNSET:
kwargs["allowed_domains"] = value
spider = crawler._create_spider(name="a", **kwargs)
mw = OffsiteMiddleware.from_crawler(crawler)
mw.spider_opened(spider)
request = Request("https://example.com")
assert mw.request_scheduled(request, spider) is None
def test_request_scheduled_invalid_domains():
crawler = get_crawler(Spider)
allowed_domains = ["a.example", None, "http:////b.example", "//c.example"]
spider = crawler._create_spider(name="a", allowed_domains=allowed_domains)
mw = OffsiteMiddleware.from_crawler(crawler)
mw.spider_opened(spider)
request = Request("https://a.example")
assert mw.request_scheduled(request, spider) is None
for letter in ("b", "c"):
request = Request(f"https://{letter}.example")
with pytest.raises(IgnoreRequest):
mw.request_scheduled(request, spider)

File diff suppressed because it is too large Load Diff

View File

@ -15,8 +15,10 @@ import subprocess
import sys
from collections import defaultdict
from dataclasses import dataclass
from logging import DEBUG
from pathlib import Path
from threading import Timer
from unittest.mock import Mock
from urllib.parse import urlparse
import attr
@ -27,11 +29,13 @@ from twisted.trial import unittest
from twisted.web import server, static, util
from scrapy import signals
from scrapy.core.engine import ExecutionEngine
from scrapy.exceptions import CloseSpider
from scrapy.core.engine import ExecutionEngine, Slot
from scrapy.core.scheduler import BaseScheduler
from scrapy.exceptions import CloseSpider, IgnoreRequest
from scrapy.http import Request
from scrapy.item import Field, Item
from scrapy.linkextractors import LinkExtractor
from scrapy.signals import request_scheduled
from scrapy.spiders import Spider
from scrapy.utils.signal import disconnect_all
from scrapy.utils.test import get_crawler
@ -467,6 +471,38 @@ class EngineTest(unittest.TestCase):
self.assertNotIn(b"Traceback", stderr)
def test_request_scheduled_signal(caplog):
class TestScheduler(BaseScheduler):
def __init__(self):
self.enqueued = []
def enqueue_request(self, request: Request) -> bool:
self.enqueued.append(request)
return True
def signal_handler(request: Request, spider: Spider) -> None:
if "drop" in request.url:
raise IgnoreRequest
spider = TestSpider()
crawler = get_crawler(spider.__class__)
engine = ExecutionEngine(crawler, lambda _: None)
engine.downloader._slot_gc_loop.stop()
scheduler = TestScheduler()
engine.slot = Slot((), None, Mock(), scheduler)
crawler.signals.connect(signal_handler, request_scheduled)
keep_request = Request("https://keep.example")
engine._schedule_request(keep_request, spider)
drop_request = Request("https://drop.example")
caplog.set_level(DEBUG)
engine._schedule_request(drop_request, spider)
assert scheduler.enqueued == [
keep_request
], f"{scheduler.enqueued!r} != [{keep_request!r}]"
assert "dropped request <GET https://drop.example>" in caplog.text
crawler.signals.disconnect(signal_handler, request_scheduled)
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "runserver":
start_test_site(debug=True)

View File

@ -1732,6 +1732,7 @@ class FeedExportTest(FeedExportTestBase):
def store(self, file):
Storage.store_file = file
Storage.file_was_closed = file.closed
file.close()
settings = {
@ -1747,6 +1748,7 @@ class FeedExportTest(FeedExportTestBase):
}
yield self.exported_no_data(settings)
self.assertIs(Storage.open_file, Storage.store_file)
self.assertFalse(Storage.file_was_closed)
class FeedPostProcessedExportsTest(FeedExportTestBase):

View File

@ -43,6 +43,13 @@ class WrappedRequestTest(TestCase):
def test_get_header(self):
self.assertEqual(self.wrapped.get_header("content-type"), "text/html")
self.assertEqual(self.wrapped.get_header("xxxxx", "def"), "def")
self.assertEqual(self.wrapped.get_header("xxxxx"), None)
wrapped = WrappedRequest(
Request(
"http://www.example.com/page.html", headers={"empty-binary-header": b""}
)
)
self.assertEqual(wrapped.get_header("empty-binary-header"), "")
def test_header_items(self):
self.assertEqual(self.wrapped.header_items(), [("Content-Type", ["text/html"])])

View File

@ -744,6 +744,118 @@ class Base:
lx = self.extractor_cls()
self.assertIsInstance(pickle.loads(pickle.dumps(lx)), self.extractor_cls)
def test_link_extractor_aggregation(self):
"""When a parameter like restrict_css is used, the underlying
implementation calls its internal link extractor once per selector
matching the specified restrictions, and then aggregates the
extracted links.
Test that aggregation respects the unique and canonicalize
parameters.
"""
# unique=True (default), canonicalize=False (default)
lx = self.extractor_cls(restrict_css=("div",))
response = HtmlResponse(
"https://example.com",
body=b"""
<div>
<a href="/a">a1</a>
<a href="/b?a=1&b=2">b1</a>
</div>
<div>
<a href="/a">a2</a>
<a href="/b?b=2&a=1">b2</a>
</div>
""",
)
actual = lx.extract_links(response)
self.assertEqual(
actual,
[
Link(url="https://example.com/a", text="a1"),
Link(url="https://example.com/b?a=1&b=2", text="b1"),
Link(url="https://example.com/b?b=2&a=1", text="b2"),
],
)
# unique=True (default), canonicalize=True
lx = self.extractor_cls(restrict_css=("div",), canonicalize=True)
response = HtmlResponse(
"https://example.com",
body=b"""
<div>
<a href="/a">a1</a>
<a href="/b?a=1&b=2">b1</a>
</div>
<div>
<a href="/a">a2</a>
<a href="/b?b=2&a=1">b2</a>
</div>
""",
)
actual = lx.extract_links(response)
self.assertEqual(
actual,
[
Link(url="https://example.com/a", text="a1"),
Link(url="https://example.com/b?a=1&b=2", text="b1"),
],
)
# unique=False, canonicalize=False (default)
lx = self.extractor_cls(restrict_css=("div",), unique=False)
response = HtmlResponse(
"https://example.com",
body=b"""
<div>
<a href="/a">a1</a>
<a href="/b?a=1&b=2">b1</a>
</div>
<div>
<a href="/a">a2</a>
<a href="/b?b=2&a=1">b2</a>
</div>
""",
)
actual = lx.extract_links(response)
self.assertEqual(
actual,
[
Link(url="https://example.com/a", text="a1"),
Link(url="https://example.com/b?a=1&b=2", text="b1"),
Link(url="https://example.com/a", text="a2"),
Link(url="https://example.com/b?b=2&a=1", text="b2"),
],
)
# unique=False, canonicalize=True
lx = self.extractor_cls(
restrict_css=("div",), unique=False, canonicalize=True
)
response = HtmlResponse(
"https://example.com",
body=b"""
<div>
<a href="/a">a1</a>
<a href="/b?a=1&b=2">b1</a>
</div>
<div>
<a href="/a">a2</a>
<a href="/b?b=2&a=1">b2</a>
</div>
""",
)
actual = lx.extract_links(response)
self.assertEqual(
actual,
[
Link(url="https://example.com/a", text="a1"),
Link(url="https://example.com/b?a=1&b=2", text="b1"),
Link(url="https://example.com/a", text="a2"),
Link(url="https://example.com/b?a=1&b=2", text="b2"),
],
)
class LxmlLinkExtractorTestCase(Base.LinkExtractorTestCase):
extractor_cls = LxmlLinkExtractor

View File

@ -8,6 +8,7 @@ from w3lib.url import add_or_replace_parameter
from scrapy import signals
from scrapy.crawler import CrawlerRunner
from scrapy.utils.misc import load_object
from tests.mockserver import MockServer
from tests.spiders import SimpleSpider
@ -192,6 +193,29 @@ class FileDownloadCrawlTestCase(TestCase):
crawler.stats.get_value("downloader/response_status_count/302"), 3
)
@defer.inlineCallbacks
def test_download_media_file_path_error(self):
cls = load_object(self.pipeline_class)
class ExceptionRaisingMediaPipeline(cls):
def file_path(self, request, response=None, info=None, *, item=None):
return 1 / 0
settings = {
**self.settings,
"ITEM_PIPELINES": {ExceptionRaisingMediaPipeline: 1},
}
runner = CrawlerRunner(settings)
crawler = self._create_crawler(MediaDownloadSpider, runner=runner)
with LogCapture() as log:
yield crawler.crawl(
self.mockserver.url("/files/images/"),
media_key=self.media_key,
media_urls_key=self.media_urls_key,
mockserver=self.mockserver,
)
self.assertIn("ZeroDivisionError", str(log))
try:
from PIL import Image # noqa: imported just to check for the import error

View File

@ -1,5 +1,7 @@
from twisted.trial import unittest
from scrapy.robotstxt import decode_robotstxt
def reppy_available():
# check if reppy parser is installed
@ -141,6 +143,25 @@ class BaseRobotParserTest:
)
class DecodeRobotsTxtTest(unittest.TestCase):
def test_native_string_conversion(self):
robotstxt_body = "User-agent: *\nDisallow: /\n".encode("utf-8")
decoded_content = decode_robotstxt(
robotstxt_body, spider=None, to_native_str_type=True
)
self.assertEqual(decoded_content, "User-agent: *\nDisallow: /\n")
def test_decode_utf8(self):
robotstxt_body = "User-agent: *\nDisallow: /\n".encode("utf-8")
decoded_content = decode_robotstxt(robotstxt_body, spider=None)
self.assertEqual(decoded_content, "User-agent: *\nDisallow: /\n")
def test_decode_non_utf8(self):
robotstxt_body = b"User-agent: *\n\xFFDisallow: /\n"
decoded_content = decode_robotstxt(robotstxt_body, spider=None)
self.assertEqual(decoded_content, "User-agent: *\nDisallow: /\n")
class PythonRobotParserTest(BaseRobotParserTest, unittest.TestCase):
def setUp(self):
from scrapy.robotstxt import PythonRobotParser

View File

@ -546,6 +546,6 @@ class TestHelper(unittest.TestCase):
def _assert_type_and_value(self, a, b, obj):
self.assertTrue(
type(a) is type(b), f"Got {type(a)}, expected {type(b)} for { obj!r}"
type(a) is type(b), f"Got {type(a)}, expected {type(b)} for {obj!r}"
)
self.assertEqual(a, b)

View File

@ -1,18 +1,26 @@
import json
import logging
import re
import sys
import unittest
from io import StringIO
from typing import Any, Dict, Mapping, MutableMapping
from unittest import TestCase
import pytest
from testfixtures import LogCapture
from twisted.python.failure import Failure
from scrapy.extensions import telnet
from scrapy.utils.log import (
LogCounterHandler,
SpiderLoggerAdapter,
StreamLogger,
TopLevelFormatter,
failure_to_exc_info,
)
from scrapy.utils.test import get_crawler
from tests.spiders import LogSpider
class FailureToExcInfoTest(unittest.TestCase):
@ -106,3 +114,181 @@ class StreamLoggerTest(unittest.TestCase):
with LogCapture() as log:
print("test log msg")
log.check(("test", "ERROR", "test log msg"))
@pytest.mark.parametrize(
("base_extra", "log_extra", "expected_extra"),
(
(
{"spider": "test"},
{"extra": {"log_extra": "info"}},
{"extra": {"log_extra": "info", "spider": "test"}},
),
(
{"spider": "test"},
{"extra": None},
{"extra": {"spider": "test"}},
),
(
{"spider": "test"},
{"extra": {"spider": "test2"}},
{"extra": {"spider": "test"}},
),
),
)
def test_spider_logger_adapter_process(
base_extra: Mapping[str, Any], log_extra: MutableMapping, expected_extra: Dict
):
logger = logging.getLogger("test")
spider_logger_adapter = SpiderLoggerAdapter(logger, base_extra)
log_message = "test_log_message"
result_message, result_kwargs = spider_logger_adapter.process(
log_message, log_extra
)
assert result_message == log_message
assert result_kwargs == expected_extra
class LoggingTestCase(TestCase):
def setUp(self):
self.log_stream = StringIO()
handler = logging.StreamHandler(self.log_stream)
logger = logging.getLogger("log_spider")
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
self.handler = handler
self.logger = logger
self.spider = LogSpider()
def tearDown(self):
self.logger.removeHandler(self.handler)
def test_debug_logging(self):
log_message = "Foo message"
self.spider.log_debug(log_message)
log_contents = self.log_stream.getvalue()
assert log_contents == f"{log_message}\n"
def test_info_logging(self):
log_message = "Bar message"
self.spider.log_info(log_message)
log_contents = self.log_stream.getvalue()
assert log_contents == f"{log_message}\n"
def test_warning_logging(self):
log_message = "Baz message"
self.spider.log_warning(log_message)
log_contents = self.log_stream.getvalue()
assert log_contents == f"{log_message}\n"
def test_error_logging(self):
log_message = "Foo bar message"
self.spider.log_error(log_message)
log_contents = self.log_stream.getvalue()
assert log_contents == f"{log_message}\n"
def test_critical_logging(self):
log_message = "Foo bar baz message"
self.spider.log_critical(log_message)
log_contents = self.log_stream.getvalue()
assert log_contents == f"{log_message}\n"
class LoggingWithExtraTestCase(TestCase):
def setUp(self):
self.log_stream = StringIO()
handler = logging.StreamHandler(self.log_stream)
formatter = logging.Formatter(
'{"levelname": "%(levelname)s", "message": "%(message)s", '
'"spider": "%(spider)s", "important_info": "%(important_info)s"}'
)
handler.setFormatter(formatter)
logger = logging.getLogger("log_spider")
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
self.handler = handler
self.logger = logger
self.spider = LogSpider()
self.regex_pattern = re.compile(r"^<LogSpider\s'log_spider'\sat\s[^>]+>$")
def tearDown(self):
self.logger.removeHandler(self.handler)
def test_debug_logging(self):
log_message = "Foo message"
extra = {"important_info": "foo"}
self.spider.log_debug(log_message, extra)
log_contents = self.log_stream.getvalue()
log_contents = json.loads(log_contents)
assert log_contents["levelname"] == "DEBUG"
assert log_contents["message"] == log_message
assert self.regex_pattern.match(log_contents["spider"])
assert log_contents["important_info"] == extra["important_info"]
def test_info_logging(self):
log_message = "Bar message"
extra = {"important_info": "bar"}
self.spider.log_info(log_message, extra)
log_contents = self.log_stream.getvalue()
log_contents = json.loads(log_contents)
assert log_contents["levelname"] == "INFO"
assert log_contents["message"] == log_message
assert self.regex_pattern.match(log_contents["spider"])
assert log_contents["important_info"] == extra["important_info"]
def test_warning_logging(self):
log_message = "Baz message"
extra = {"important_info": "baz"}
self.spider.log_warning(log_message, extra)
log_contents = self.log_stream.getvalue()
log_contents = json.loads(log_contents)
assert log_contents["levelname"] == "WARNING"
assert log_contents["message"] == log_message
assert self.regex_pattern.match(log_contents["spider"])
assert log_contents["important_info"] == extra["important_info"]
def test_error_logging(self):
log_message = "Foo bar message"
extra = {"important_info": "foo bar"}
self.spider.log_error(log_message, extra)
log_contents = self.log_stream.getvalue()
log_contents = json.loads(log_contents)
assert log_contents["levelname"] == "ERROR"
assert log_contents["message"] == log_message
assert self.regex_pattern.match(log_contents["spider"])
assert log_contents["important_info"] == extra["important_info"]
def test_critical_logging(self):
log_message = "Foo bar baz message"
extra = {"important_info": "foo bar baz"}
self.spider.log_critical(log_message, extra)
log_contents = self.log_stream.getvalue()
log_contents = json.loads(log_contents)
assert log_contents["levelname"] == "CRITICAL"
assert log_contents["message"] == log_message
assert self.regex_pattern.match(log_contents["spider"])
assert log_contents["important_info"] == extra["important_info"]
def test_overwrite_spider_extra(self):
log_message = "Foo message"
extra = {"important_info": "foo", "spider": "shouldn't change"}
self.spider.log_error(log_message, extra)
log_contents = self.log_stream.getvalue()
log_contents = json.loads(log_contents)
assert log_contents["levelname"] == "ERROR"
assert log_contents["message"] == log_message
assert self.regex_pattern.match(log_contents["spider"])
assert log_contents["important_info"] == extra["important_info"]

View File

@ -6,6 +6,7 @@ import unittest
import warnings
from pathlib import Path
from scrapy.utils.misc import set_environ
from scrapy.utils.project import data_path, get_project_settings
@ -38,20 +39,6 @@ class ProjectUtilsTest(unittest.TestCase):
self.assertEqual(abspath, data_path(abspath))
@contextlib.contextmanager
def set_env(**update):
modified = set(update.keys()) & set(os.environ.keys())
update_after = {k: os.environ[k] for k in modified}
remove_after = frozenset(k for k in update if k not in os.environ)
try:
os.environ.update(update)
yield
finally:
os.environ.update(update_after)
for k in remove_after:
os.environ.pop(k)
class GetProjectSettingsTestCase(unittest.TestCase):
def test_valid_envvar(self):
value = "tests.test_cmdline.settings"
@ -60,7 +47,7 @@ class GetProjectSettingsTestCase(unittest.TestCase):
}
with warnings.catch_warnings():
warnings.simplefilter("error")
with set_env(**envvars):
with set_environ(**envvars):
settings = get_project_settings()
assert settings.get("SETTINGS_MODULE") == value
@ -69,7 +56,7 @@ class GetProjectSettingsTestCase(unittest.TestCase):
envvars = {
"SCRAPY_FOO": "bar",
}
with set_env(**envvars):
with set_environ(**envvars):
settings = get_project_settings()
assert settings.get("SCRAPY_FOO") is None
@ -80,7 +67,7 @@ class GetProjectSettingsTestCase(unittest.TestCase):
"SCRAPY_FOO": "bar",
"SCRAPY_SETTINGS_MODULE": value,
}
with set_env(**envvars):
with set_environ(**envvars):
settings = get_project_settings()
assert settings.get("SETTINGS_MODULE") == value
assert settings.get("SCRAPY_FOO") is None

View File

@ -239,8 +239,11 @@ class UtilsPythonTestCase(unittest.TestCase):
self.assertEqual(get_func_args(" ".join, stripself=True), ["iterable"])
if platform.python_implementation() == "CPython":
# doesn't work on CPython: https://bugs.python.org/issue42785
self.assertEqual(get_func_args(operator.itemgetter(2)), [])
# This didn't work on older versions of CPython: https://github.com/python/cpython/issues/86951
self.assertIn(
get_func_args(operator.itemgetter(2), stripself=True),
[[], ["args", "kwargs"]],
)
elif platform.python_implementation() == "PyPy":
self.assertEqual(
get_func_args(operator.itemgetter(2), stripself=True), ["obj"]

18
tox.ini
View File

@ -16,6 +16,9 @@ deps =
#mitmproxy >= 5.3.0; python_version >= '3.9' and implementation_name != 'pypy'
# The tests hang with mitmproxy 8.0.0: https://github.com/scrapy/scrapy/issues/5454
mitmproxy >= 4.0.4, < 8; python_version < '3.9' and implementation_name != 'pypy'
# https://github.com/pallets/werkzeug/pull/2768 breaks flask, required by
# mitmproxy.
werkzeug < 3; python_version < '3.9' and implementation_name != 'pypy'
passenv =
S3_TEST_FILE_URI
AWS_ACCESS_KEY_ID
@ -71,6 +74,7 @@ commands =
twine check dist/*
[pinned]
basepython = python3.8
deps =
cryptography==36.0.0
cssselect==0.9.1
@ -97,7 +101,7 @@ commands =
pytest --cov=scrapy --cov-report=xml --cov-report= {posargs:--durations=10 scrapy tests}
[testenv:pinned]
basepython = python3.8
basepython = {[pinned]basepython}
deps =
{[pinned]deps}
PyDispatcher==2.0.5
@ -107,7 +111,7 @@ setenv =
commands = {[pinned]commands}
[testenv:windows-pinned]
basepython = python3
basepython = {[pinned]basepython}
deps =
{[pinned]deps}
PyDispatcher==2.0.5
@ -130,13 +134,14 @@ deps =
Twisted[http2]
[testenv:extra-deps-pinned]
basepython = python3.8
basepython = {[pinned]basepython}
deps =
{[pinned]deps}
boto3==1.20.0
google-cloud-storage==1.29.0
Pillow==7.1.0
robotexclusionrulesparser==1.6.2
brotlipy
install_command = {[pinned]install_command}
setenv =
{[pinned]setenv}
@ -147,6 +152,7 @@ commands =
{[testenv]commands} --reactor=asyncio
[testenv:asyncio-pinned]
basepython = {[pinned]basepython}
deps = {[testenv:pinned]deps}
commands = {[pinned]commands} --reactor=asyncio
install_command = {[pinned]install_command}
@ -159,12 +165,12 @@ commands =
pytest {posargs:--durations=10 docs scrapy tests}
[testenv:pypy3-pinned]
basepython = {[testenv:pypy3]basepython}
basepython = pypy3.8
deps =
{[pinned]deps}
PyPyDispatcher==2.1.0
commands =
pytest --durations=10 scrapy tests
pytest {posargs:--durations=10 scrapy tests}
install_command = {[pinned]install_command}
setenv =
{[pinned]setenv}
@ -212,7 +218,7 @@ commands =
pytest --cov=scrapy --cov-report=xml --cov-report= {posargs:tests -k s3}
[testenv:botocore-pinned]
basepython = python3.8
basepython = {[pinned]basepython}
deps =
{[pinned]deps}
botocore==1.4.87