Merge branch 'master' into py3.15

This commit is contained in:
Andrey Rakhmatullin 2026-07-01 11:55:49 +05:00
commit da7a6c8e29
190 changed files with 3640 additions and 1246 deletions

View File

@ -27,6 +27,6 @@ repos:
hooks: hooks:
- id: sphinx-lint - id: sphinx-lint
- repo: https://github.com/scrapy/sphinx-scrapy - repo: https://github.com/scrapy/sphinx-scrapy
rev: 0.8.6 rev: 0.8.8
hooks: hooks:
- id: sphinx-scrapy - id: sphinx-scrapy

View File

@ -74,40 +74,19 @@ def mockserver() -> Generator[MockServer]:
@pytest.fixture # function scope because it modifies os.environ @pytest.fixture # function scope because it modifies os.environ
def mitm_proxy_server(monkeypatch: pytest.MonkeyPatch) -> Generator[MitmProxy]: def proxy_server(
proxy = MitmProxy() request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch
) -> Generator[str]:
kind = request.param
proxy = MitmProxy(mode="socks5" if kind == "socks5" else None)
url = proxy.start() url = proxy.start()
if kind == "https":
url = url.replace("http://", "https://")
monkeypatch.setenv("http_proxy", url) monkeypatch.setenv("http_proxy", url)
monkeypatch.setenv("https_proxy", url) monkeypatch.setenv("https_proxy", url)
try: try:
yield proxy yield kind
finally:
proxy.stop()
@pytest.fixture # function scope because it modifies os.environ
def mitm_proxy_server_https(monkeypatch: pytest.MonkeyPatch) -> Generator[MitmProxy]:
proxy = MitmProxy()
url = proxy.start().replace("http://", "https://")
monkeypatch.setenv("http_proxy", url)
monkeypatch.setenv("https_proxy", url)
try:
yield proxy
finally:
proxy.stop()
@pytest.fixture # function scope because it modifies os.environ
def socks5_proxy_server(monkeypatch: pytest.MonkeyPatch) -> Generator[MitmProxy]:
proxy = MitmProxy(mode="socks5")
url = proxy.start()
monkeypatch.setenv("http_proxy", url)
monkeypatch.setenv("https_proxy", url)
try:
yield proxy
finally: finally:
proxy.stop() proxy.stop()

View File

@ -158,6 +158,7 @@ scrapy_intersphinx_enable = [
"itemloaders", "itemloaders",
"parsel", "parsel",
"pytest", "pytest",
"scrapy-lint",
"sphinx", "sphinx",
"tox", "tox",
"twisted", "twisted",

View File

@ -151,6 +151,7 @@ Solving specific problems
topics/debug topics/debug
topics/contracts topics/contracts
topics/practices topics/practices
topics/security
topics/broad-crawls topics/broad-crawls
topics/developer-tools topics/developer-tools
topics/dynamic-content topics/dynamic-content
@ -175,6 +176,10 @@ Solving specific problems
:doc:`topics/practices` :doc:`topics/practices`
Get familiar with some Scrapy common practices. Get familiar with some Scrapy common practices.
:doc:`topics/security`
Understand the security implications of Scrapy defaults and how to harden
them.
:doc:`topics/broad-crawls` :doc:`topics/broad-crawls`
Tune Scrapy for crawling a lot domains in parallel. Tune Scrapy for crawling a lot domains in parallel.

View File

@ -5,4 +5,4 @@ sphinx
sphinx-notfound-page sphinx-notfound-page
sphinx-rtd-theme sphinx-rtd-theme
sphinx-rtd-dark-mode sphinx-rtd-dark-mode
sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.6 sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.8

View File

@ -153,7 +153,7 @@ sphinx-rtd-theme==3.1.0
# via # via
# -r docs/requirements.in # -r docs/requirements.in
# sphinx-rtd-dark-mode # sphinx-rtd-dark-mode
sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@b1d55db4d16a5425fc68576d63519bbfe26dd9c0 sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@c0b2ac815afc3cb8857d575cecb5d55c05e6b737
# via -r docs/requirements.in # via -r docs/requirements.in
sphinx-sitemap==2.9.0 sphinx-sitemap==2.9.0
# via sphinx-scrapy # via sphinx-scrapy

View File

@ -39,6 +39,10 @@ for additional schemes and to replace or disable default ones:
"sftp": "my.download_handlers.SftpHandler", "sftp": "my.download_handlers.SftpHandler",
} }
.. seealso:: :ref:`security-unencrypted-protocols` and
:ref:`security-local-resources`, for the security implications of the
default ``http``, ``ftp``, ``file`` and ``data`` handlers.
Replacing HTTP(S) download handlers Replacing HTTP(S) download handlers
----------------------------------- -----------------------------------

View File

@ -379,6 +379,8 @@ that this risks leaking credentials to unrelated domains.
This setting must be explicitly configured whenever :setting:`HTTPAUTH_USER` This setting must be explicitly configured whenever :setting:`HTTPAUTH_USER`
or :setting:`HTTPAUTH_PASS` is set. or :setting:`HTTPAUTH_PASS` is set.
.. seealso:: :ref:`security-credential-leakage`
.. _Basic access authentication: https://en.wikipedia.org/wiki/Basic_access_authentication .. _Basic access authentication: https://en.wikipedia.org/wiki/Basic_access_authentication

View File

@ -57,6 +57,8 @@ Any of these methods may be defined as a coroutine function (``async def``).
Item pipeline example Item pipeline example
===================== =====================
.. _price-pipeline-example:
Price validation and dropping items with no prices Price validation and dropping items with no prices
-------------------------------------------------- --------------------------------------------------
@ -246,6 +248,8 @@ returns multiples items with the same id:
return item return item
.. _activating-item-pipeline:
Activating an Item Pipeline component Activating an Item Pipeline component
===================================== =====================================
@ -262,3 +266,110 @@ To activate an Item Pipeline component you must add its class to the
The integer values you assign to classes in this setting determine the The integer values you assign to classes in this setting determine the
order in which they run: items go through from lower valued to higher order in which they run: items go through from lower valued to higher
valued classes. It's customary to define these numbers in the 0-1000 range. valued classes. It's customary to define these numbers in the 0-1000 range.
A complete example
==================
The examples above show item pipeline components on their own. In a project, a
pipeline is one of four pieces that work together: the :ref:`item
<topics-items>` your spider produces, the :ref:`spider <topics-spiders>` that
yields it, the pipeline that processes it, and the :setting:`ITEM_PIPELINES`
setting that enables the pipeline.
The following example wires those pieces together to validate the price of
books scraped from `books.toscrape.com`_, reusing the ``PricePipeline`` from
:ref:`price-pipeline-example` above.
Define the item in ``myproject/items.py``:
.. code-block:: python
from dataclasses import dataclass
@dataclass
class BookItem:
title: str
price: float
Yield instances of that item from your spider, e.g. in
``myproject/spiders/books.py``:
.. skip: next
.. code-block:: python
import scrapy
from myproject.items import BookItem
class BooksSpider(scrapy.Spider):
name = "books"
start_urls = ["https://books.toscrape.com/"]
def parse(self, response):
for book in response.css("article.product_pod"):
yield BookItem(
title=book.css("h3 a::attr(title)").get(),
price=float(book.css("p.price_color::text").re_first(r"[\d.]+")),
)
Put the ``PricePipeline`` shown earlier in ``myproject/pipelines.py``, and
enable it in ``myproject/settings.py``:
.. code-block:: python
ITEM_PIPELINES = {
"myproject.pipelines.PricePipeline": 300,
}
With these pieces in place, every ``BookItem`` that ``BooksSpider`` yields
passes through ``PricePipeline`` before it reaches the :ref:`feed exports
<topics-feed-exports>` or any other output.
.. _books.toscrape.com: https://books.toscrape.com/
Common pitfalls
===============
The pipeline does not run
-------------------------
A pipeline component only runs if its class is listed in the
:setting:`ITEM_PIPELINES` setting, normally in your project's
:file:`settings.py` file (see :ref:`activating-item-pipeline`). Adding it to
the spider or elsewhere has no effect.
To confirm that Scrapy loaded your pipeline, look for a line like this near the
start of the crawl log::
[scrapy.middleware] INFO: Enabled item pipelines:
['myproject.pipelines.PricePipeline']
If your pipeline is missing from that list, check that its import path matches
the :setting:`ITEM_PIPELINES` entry, and that the setting is not being
overridden, for example by :attr:`~scrapy.Spider.custom_settings` or by a
redefinition of :setting:`ITEM_PIPELINES` in :file:`settings.py`.
The item is not returned
------------------------
:meth:`process_item` must return the item (or raise
:exc:`~scrapy.exceptions.DropItem`). A common mistake is to modify the item but
forget to return it:
.. code-block:: python
def process_item(self, item):
ItemAdapter(item)["price"] *= 1.15
# Bug: returns None, so the next component gets None instead of the item.
Return the item so that the next component, and the rest of Scrapy, can keep
processing it:
.. code-block:: python
def process_item(self, item):
ItemAdapter(item)["price"] *= 1.15
return item

View File

@ -774,4 +774,28 @@ To enable your custom media pipeline component you must add its class import pat
ITEM_PIPELINES = {"myproject.pipelines.MyImagesPipeline": 300} ITEM_PIPELINES = {"myproject.pipelines.MyImagesPipeline": 300}
Content-based image filtering pipeline
--------------------------------------
This example overrides ``get_images()`` to filter images using a classifier,
such as a TensorFlow_ model. Override ``is_valid_image()`` with your
classification logic:
.. code-block:: python
from scrapy.pipelines.images import ImagesPipeline, ImageException
class ImageClassifierPipeline(ImagesPipeline):
def is_valid_image(self, image):
raise NotImplementedError
def get_images(self, response, request, info, *, item=None):
for path, image, buf in super().get_images(response, request, info, item=item):
if not self.is_valid_image(image):
raise ImageException("Image does not match criteria")
yield path, image, buf
.. _MD5 hash: https://en.wikipedia.org/wiki/MD5 .. _MD5 hash: https://en.wikipedia.org/wiki/MD5
.. _TensorFlow: https://tensorflow.org

View File

@ -347,10 +347,10 @@ finishes before starting the next one:
install_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor") install_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor")
react(deferred_f_from_coro_f(crawl)) react(deferred_f_from_coro_f(crawl))
.. note:: When running multiple spiders in the same process, :ref:`reactor .. note:: When running multiple spiders in the same process, :ref:`logging
settings <reactor-settings>` should not have a different value per spider. settings <logging-settings>` and :ref:`reactor settings <reactor-settings>`
Also, :ref:`pre-crawler settings <pre-crawler-settings>` cannot be defined should not have a different value per spider, and :ref:`pre-crawler
per spider. settings <pre-crawler-settings>` cannot be defined per spider.
.. seealso:: :ref:`run-from-script`. .. seealso:: :ref:`run-from-script`.
@ -440,6 +440,14 @@ Here are some tips to keep in mind when dealing with these kinds of sites:
If you are still unable to prevent your bot getting banned, consider contacting If you are still unable to prevent your bot getting banned, consider contacting
`commercial support`_. `commercial support`_.
.. _static-analysis:
Static analysis
===============
Consider using :doc:`scrapy-lint <scrapy-lint:index>`, a linter for Scrapy
projects that detects common mistakes and anti-patterns.
.. _Tor project: https://www.torproject.org/ .. _Tor project: https://www.torproject.org/
.. _commercial support: https://www.scrapy.org/companies .. _commercial support: https://www.scrapy.org/companies
.. _ProxyMesh: https://proxymesh.com/ .. _ProxyMesh: https://proxymesh.com/

View File

@ -63,7 +63,7 @@ Request objects
.. invisible-code-block: python .. invisible-code-block: python
from scrapy.http import Request from scrapy import Request
1. Using a dict: 1. Using a dict:
@ -911,6 +911,11 @@ Request subclasses
Here is the list of built-in :class:`~scrapy.Request` subclasses. You can also subclass Here is the list of built-in :class:`~scrapy.Request` subclasses. You can also subclass
it to implement your own custom functionality. it to implement your own custom functionality.
FormRequest
-----------
.. autoclass:: scrapy.FormRequest
JsonRequest JsonRequest
----------- -----------

207
docs/topics/security.rst Normal file
View File

@ -0,0 +1,207 @@
.. _security:
========
Security
========
Scrapy defaults are optimized for web scraping, not for the security posture
that you might expect from software that handles untrusted input or runs in a
shared or exposed environment. Some common security practices are unnecessary
for many scraping use cases, and a few can even prevent valid ones (for
example, sites that you must scrape may use misconfigured TLS certificates or
serve content over unencrypted protocols).
This page highlights the Scrapy defaults that have security implications, so
that you can make an informed decision about whether to keep them, and explains
how to harden them along with the trade-offs involved.
.. note::
None of the options below are silver bullets. Which of them make sense
depends on your threat model: whether the URLs you crawl come from trusted
sources, whether the machine running Scrapy is exposed to a network you do
not control, whether the data you handle is sensitive, and so on.
.. _security-untrusted-responses:
Treat responses as untrusted input
==================================
Regardless of any setting, remember that response data comes from servers you
do not control, even when you trust the site you are crawling, as responses may
be tampered with in transit or the server itself may be compromised.
Never pass response data to functions that can execute code or otherwise act on
their input in an unsafe way, such as :func:`eval`, :func:`exec`, or
:func:`pickle.loads`, and be careful when writing response data to paths
derived from the response itself.
TLS connections
===============
.. _security-certificate-verification:
Certificate verification
------------------------
By default Scrapy does **not** verify the TLS certificate of HTTPS servers, as
controlled by the :setting:`DOWNLOAD_VERIFY_CERTIFICATES` setting (default:
``False``).
This default favors reach over security: many sites that are otherwise fine to
scrape have expired, self-signed, or otherwise invalid certificates, and
verifying certificates would make requests to them fail.
If the integrity of the connection matters to you (for example, to detect
man-in-the-middle attacks), set:
.. code-block:: python
DOWNLOAD_VERIFY_CERTIFICATES = True
* **Pro:** requests to servers with invalid or untrusted certificates fail
instead of silently succeeding, protecting you from some man-in-the-middle
attacks.
* **Con:** you can no longer scrape sites with misconfigured certificates
without re-disabling verification for them.
.. _security-tls-protocols-ciphers:
Protocol versions and ciphers
-----------------------------
You can restrict the TLS protocol versions that Scrapy accepts through the
:setting:`DOWNLOAD_TLS_MIN_VERSION` and :setting:`DOWNLOAD_TLS_MAX_VERSION`
settings, e.g. to reject obsolete protocol versions.
By default Scrapy uses the OpenSSL ``DEFAULT`` cipher list
(:setting:`DOWNLOADER_CLIENT_TLS_CIPHERS`), which favors compatibility and still
allows some older, weaker ciphers. Set it to ``None`` to instead use the curated
cipher list of the underlying TLS implementation (Twisted), which excludes weak
ciphers:
.. code-block:: python
DOWNLOADER_CLIENT_TLS_CIPHERS = None
* **Pro:** connections that would negotiate a weak cipher fail instead of
succeeding.
* **Con:** you can no longer connect to servers that only support the excluded
ciphers.
.. _security-unencrypted-protocols:
Unencrypted protocols
=====================
By default Scrapy enables download handlers for unencrypted protocols, namely
``http://`` and ``ftp://`` (see :setting:`DOWNLOAD_HANDLERS_BASE`). Data sent
and received over these protocols, including any credentials, travels in plain
text and can be read or modified by anyone on the network path.
If you only crawl over encrypted protocols, you can disable the unencrypted
ones so that no request can accidentally be sent unencrypted:
.. code-block:: python
DOWNLOAD_HANDLERS = {
"http": None,
"ftp": None,
}
* **Pro:** a misconfigured or maliciously-redirected request cannot leak data
over an unencrypted connection, as such requests fail instead.
* **Con:** you can no longer crawl resources that are only available over those
protocols.
Note that disabling the ``http`` handler also prevents plain-HTTP requests that
result from following an ``http://`` redirect or link, which is often the point
of disabling it.
.. _security-local-resources:
Local and non-network resources
===============================
By default Scrapy enables download handlers for the ``file://`` and ``data:``
schemes (see :setting:`DOWNLOAD_HANDLERS_BASE`). The ``file://`` handler reads
arbitrary files from the local filesystem, limited only by the permissions of
the process running Scrapy.
This is convenient (for example, to parse a local HTML file), but it is a risk
if any of the URLs you schedule come from an untrusted source: a crafted
``file:///etc/passwd`` URL could read local files.
If you do not need them, disable these handlers:
.. code-block:: python
DOWNLOAD_HANDLERS = {
"file": None,
"data": None,
}
* **Pro:** crawled URLs cannot be used to read local files or inline data.
* **Con:** you can no longer fetch ``file://`` or ``data:`` URLs.
More generally, if you crawl URLs from untrusted sources, consider validating
their schemes (and, where applicable, their hosts) before scheduling requests,
to avoid server-side request forgery (SSRF) and similar issues.
.. _security-telnet:
Telnet console
==============
Scrapy enables the :ref:`telnet console <topics-telnetconsole>` by default
(:setting:`TELNETCONSOLE_ENABLED`). The telnet console is a Python shell
running inside the Scrapy process, so anyone who can connect to it can run
arbitrary code in that process.
By default the console binds to ``127.0.0.1`` (:setting:`TELNETCONSOLE_HOST`)
and is protected by a username (:setting:`TELNETCONSOLE_USERNAME`, default
``scrapy``) and an automatically generated password
(:setting:`TELNETCONSOLE_PASSWORD`), so it is only reachable from the local
machine.
.. warning::
Telnet does not provide any transport-layer security, so the
username/password authentication does not protect the credentials or the
session from anyone able to observe the traffic. Never expose the telnet
console over an untrusted network by changing :setting:`TELNETCONSOLE_HOST`
to a non-local address.
If you do not use the telnet console, disable it entirely:
.. code-block:: python
TELNETCONSOLE_ENABLED = False
* **Pro:** removes a local code-execution surface and one less listening port.
* **Con:** you can no longer :ref:`inspect and control a running crawler
<topics-telnetconsole>` through it.
.. _security-credential-leakage:
Credential leakage across domains
=================================
Some Scrapy features attach credentials or other sensitive headers to requests,
and a crawl that spans multiple domains can leak them to unintended hosts:
* HTTP authentication credentials set through
:class:`~scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware` are only
sent to the domain set in :setting:`HTTPAUTH_DOMAIN`. Leave this set to the
intended domain rather than ``None`` so that credentials are not sent to
every domain you crawl.
* The ``Referer`` header may disclose the URLs you crawl to other sites. The
default :setting:`REFERRER_POLICY` already avoids sending the referrer from
HTTPS to HTTP, but you can tighten it further (for example, to
``same-origin`` or ``no-referrer``) if needed.

View File

@ -361,6 +361,31 @@ All of these settings, except for :setting:`ASYNCIO_EVENT_LOOP`, are only used
when the Twisted reactor is used, i.e. when :setting:`TWISTED_REACTOR_ENABLED` when the Twisted reactor is used, i.e. when :setting:`TWISTED_REACTOR_ENABLED`
is ``True``. is ``True``.
.. _logging-settings:
Logging settings
----------------
**Logging settings** are settings that configure the global root logging
handler installed by :func:`~scrapy.utils.log.configure_logging`.
These settings can be defined from a spider. However, because only 1 root
logging handler is active per process, these settings cannot use a different
value per spider when :ref:`running multiple spiders in the same process
<run-multiple-spiders>`.
These settings are:
- :setting:`LOG_DATEFORMAT`
- :setting:`LOG_ENABLED`
- :setting:`LOG_ENCODING`
- :setting:`LOG_FILE`
- :setting:`LOG_FILE_APPEND`
- :setting:`LOG_FORMAT`
- :setting:`LOG_LEVEL`
- :setting:`LOG_SHORT_NAMES`
- :setting:`LOG_STDOUT`
.. _topics-settings-ref: .. _topics-settings-ref:
Built-in settings reference Built-in settings reference
@ -481,6 +506,8 @@ Note that the event loop class must inherit from :class:`asyncio.AbstractEventLo
:func:`asyncio.set_event_loop`, which will set the specified event loop :func:`asyncio.set_event_loop`, which will set the specified event loop
as the current loop for the current OS thread. as the current loop for the current OS thread.
.. note:: This is a :ref:`reactor setting <reactor-settings>`.
.. setting:: BOT_NAME .. setting:: BOT_NAME
BOT_NAME BOT_NAME
@ -662,6 +689,8 @@ Whether to enable DNS in-memory cache.
:setting:`TWISTED_REACTOR_ENABLED` is ``False``, and may have no effect :setting:`TWISTED_REACTOR_ENABLED` is ``False``, and may have no effect
either when :setting:`DNS_RESOLVER` is set to a different resolver. either when :setting:`DNS_RESOLVER` is set to a different resolver.
.. note:: This is a :ref:`reactor setting <reactor-settings>`.
.. setting:: DNSCACHE_SIZE .. setting:: DNSCACHE_SIZE
DNSCACHE_SIZE DNSCACHE_SIZE
@ -671,6 +700,8 @@ Default: ``10000``
DNS in-memory cache size, see :setting:`DNSCACHE_ENABLED`. DNS in-memory cache size, see :setting:`DNSCACHE_ENABLED`.
.. note:: This is a :ref:`reactor setting <reactor-settings>`.
.. setting:: TWISTED_DNS_RESOLVER .. setting:: TWISTED_DNS_RESOLVER
TWISTED_DNS_RESOLVER TWISTED_DNS_RESOLVER
@ -688,6 +719,8 @@ take the :setting:`DNS_TIMEOUT` setting into account.
.. note:: .. note::
This setting has no effect when :setting:`TWISTED_REACTOR_ENABLED` is ``False``. This setting has no effect when :setting:`TWISTED_REACTOR_ENABLED` is ``False``.
.. note:: This is a :ref:`reactor setting <reactor-settings>`.
.. setting:: DNS_TIMEOUT .. setting:: DNS_TIMEOUT
DNS_TIMEOUT DNS_TIMEOUT
@ -703,6 +736,8 @@ Timeout for processing of DNS queries in seconds. Float is supported.
:setting:`TWISTED_REACTOR_ENABLED` is ``False``, and may have no effect :setting:`TWISTED_REACTOR_ENABLED` is ``False``, and may have no effect
either when :setting:`DNS_RESOLVER` is set to a different resolver. either when :setting:`DNS_RESOLVER` is set to a different resolver.
.. note:: This is a :ref:`reactor setting <reactor-settings>`.
.. setting:: DOWNLOADER .. setting:: DOWNLOADER
DOWNLOADER DOWNLOADER
@ -728,6 +763,9 @@ 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 ``'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. specific cipher that is not included in ``DEFAULT`` if a website requires it.
Set this setting to ``None`` to use the default ciphers of the underlying TLS
implementation.
.. _OpenSSL cipher list format: https://docs.openssl.org/master/man1/openssl-ciphers/#cipher-list-format .. _OpenSSL cipher list format: https://docs.openssl.org/master/man1/openssl-ciphers/#cipher-list-format
.. note:: .. note::
@ -736,6 +774,8 @@ specific cipher that is not included in ``DEFAULT`` if a website requires it.
handler <topics-download-handlers>`, so it's not guaranteed to be supported handler <topics-download-handlers>`, so it's not guaranteed to be supported
by all 3rd-party handlers. by all 3rd-party handlers.
.. seealso:: :ref:`security-tls-protocols-ciphers`
.. setting:: DOWNLOAD_TLS_MAX_VERSION .. setting:: DOWNLOAD_TLS_MAX_VERSION
DOWNLOAD_TLS_MAX_VERSION DOWNLOAD_TLS_MAX_VERSION
@ -769,6 +809,8 @@ modern environments.
by all 3rd-party handlers. Additionally, the set of supported TLS versions by all 3rd-party handlers. Additionally, the set of supported TLS versions
depends on the TLS implementation being used by the handler. depends on the TLS implementation being used by the handler.
.. seealso:: :ref:`security-tls-protocols-ciphers`
.. setting:: DOWNLOAD_TLS_MIN_VERSION .. setting:: DOWNLOAD_TLS_MIN_VERSION
DOWNLOAD_TLS_MIN_VERSION DOWNLOAD_TLS_MIN_VERSION
@ -781,6 +823,8 @@ be used by Scrapy.
See :setting:`DOWNLOAD_TLS_MAX_VERSION` for the details and limitations. See :setting:`DOWNLOAD_TLS_MAX_VERSION` for the details and limitations.
.. seealso:: :ref:`security-tls-protocols-ciphers`
.. setting:: DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING .. setting:: DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING
DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING
@ -941,6 +985,9 @@ enabled in your project.
See :setting:`DOWNLOAD_HANDLERS_BASE` for example format. See :setting:`DOWNLOAD_HANDLERS_BASE` for example format.
.. seealso:: :ref:`security-unencrypted-protocols` and
:ref:`security-local-resources`
.. setting:: DOWNLOAD_HANDLERS_BASE .. setting:: DOWNLOAD_HANDLERS_BASE
DOWNLOAD_HANDLERS_BASE DOWNLOAD_HANDLERS_BASE
@ -988,6 +1035,9 @@ handler (without replacement), place this in your ``settings.py``:
"ftp": None, "ftp": None,
} }
.. seealso:: :ref:`security-unencrypted-protocols` and
:ref:`security-local-resources`
.. setting:: DOWNLOAD_SLOTS .. setting:: DOWNLOAD_SLOTS
@ -1146,6 +1196,8 @@ when making a request and abort the request if the verification fails.
certificate problems are logged when this setting is set to ``False``) certificate problems are logged when this setting is set to ``False``)
depends on its implementation. depends on its implementation.
.. seealso:: :ref:`security-certificate-verification`
.. setting:: DUPEFILTER_CLASS .. setting:: DUPEFILTER_CLASS
DUPEFILTER_CLASS DUPEFILTER_CLASS
@ -1442,6 +1494,8 @@ Default: ``True``
Whether to enable logging. Whether to enable logging.
.. note:: This is a :ref:`logging setting <logging-settings>`.
.. setting:: LOG_ENCODING .. setting:: LOG_ENCODING
LOG_ENCODING LOG_ENCODING
@ -1451,6 +1505,8 @@ Default: ``'utf-8'``
The encoding to use for logging. The encoding to use for logging.
.. note:: This is a :ref:`logging setting <logging-settings>`.
.. setting:: LOG_FILE .. setting:: LOG_FILE
LOG_FILE LOG_FILE
@ -1460,6 +1516,8 @@ Default: ``None``
File name to use for logging output. If ``None``, standard error will be used. File name to use for logging output. If ``None``, standard error will be used.
.. note:: This is a :ref:`logging setting <logging-settings>`.
.. setting:: LOG_FILE_APPEND .. setting:: LOG_FILE_APPEND
LOG_FILE_APPEND LOG_FILE_APPEND
@ -1470,6 +1528,8 @@ Default: ``True``
If ``False``, the log file specified with :setting:`LOG_FILE` will be If ``False``, the log file specified with :setting:`LOG_FILE` will be
overwritten (discarding the output from previous runs, if any). overwritten (discarding the output from previous runs, if any).
.. note:: This is a :ref:`logging setting <logging-settings>`.
.. setting:: LOG_FORMAT .. setting:: LOG_FORMAT
LOG_FORMAT LOG_FORMAT
@ -1481,6 +1541,8 @@ String for formatting log messages. Refer to the
:ref:`Python logging documentation <logrecord-attributes>` for the whole :ref:`Python logging documentation <logrecord-attributes>` for the whole
list of available placeholders. list of available placeholders.
.. note:: This is a :ref:`logging setting <logging-settings>`.
.. setting:: LOG_DATEFORMAT .. setting:: LOG_DATEFORMAT
LOG_DATEFORMAT LOG_DATEFORMAT
@ -1493,6 +1555,8 @@ in :setting:`LOG_FORMAT`. Refer to the
:ref:`Python datetime documentation <strftime-strptime-behavior>` for the :ref:`Python datetime documentation <strftime-strptime-behavior>` for the
whole list of available directives. whole list of available directives.
.. note:: This is a :ref:`logging setting <logging-settings>`.
.. setting:: LOG_FORMATTER .. setting:: LOG_FORMATTER
LOG_FORMATTER LOG_FORMATTER
@ -1512,6 +1576,8 @@ Default: ``'DEBUG'``
Minimum level to log. Available levels are: CRITICAL, ERROR, WARNING, Minimum level to log. Available levels are: CRITICAL, ERROR, WARNING,
INFO, DEBUG. For more info see :ref:`topics-logging`. INFO, DEBUG. For more info see :ref:`topics-logging`.
.. note:: This is a :ref:`logging setting <logging-settings>`.
.. setting:: LOG_STDOUT .. setting:: LOG_STDOUT
LOG_STDOUT LOG_STDOUT
@ -1523,6 +1589,8 @@ 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. log.
.. note:: This is a :ref:`logging setting <logging-settings>`.
.. setting:: LOG_SHORT_NAMES .. setting:: LOG_SHORT_NAMES
LOG_SHORT_NAMES LOG_SHORT_NAMES
@ -1533,6 +1601,8 @@ Default: ``False``
If ``True``, the logs will just contain the root path. If it is set to ``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 then it displays the component responsible for the log output
.. note:: This is a :ref:`logging setting <logging-settings>`.
.. setting:: LOG_VERSIONS .. setting:: LOG_VERSIONS
LOG_VERSIONS LOG_VERSIONS
@ -1695,6 +1765,8 @@ multi-purpose thread pool used by various Scrapy components. Threaded
DNS Resolver, BlockingFeedStorage, S3FilesStore just to name a few. Increase DNS Resolver, BlockingFeedStorage, S3FilesStore just to name a few. Increase
this value if you're experiencing problems with insufficient blocking IO. this value if you're experiencing problems with insufficient blocking IO.
.. note:: This is a :ref:`reactor setting <reactor-settings>`.
.. setting:: REDIRECT_PRIORITY_ADJUST .. setting:: REDIRECT_PRIORITY_ADJUST
REDIRECT_PRIORITY_ADJUST REDIRECT_PRIORITY_ADJUST
@ -1921,6 +1993,8 @@ Default: ``'scrapy.spiderloader.SpiderLoader'``
The class that will be used for loading spiders, which must implement the The class that will be used for loading spiders, which must implement the
:ref:`topics-api-spiderloader`. :ref:`topics-api-spiderloader`.
.. note:: This is a :ref:`pre-crawler setting <pre-crawler-settings>`.
.. setting:: SPIDER_LOADER_WARN_ONLY .. setting:: SPIDER_LOADER_WARN_ONLY
SPIDER_LOADER_WARN_ONLY SPIDER_LOADER_WARN_ONLY
@ -1933,6 +2007,8 @@ it will fail loudly if there is any ``ImportError`` or ``SyntaxError`` exception
But you can choose to silence this exception and turn it into a simple But you can choose to silence this exception and turn it into a simple
warning by setting ``SPIDER_LOADER_WARN_ONLY = True``. warning by setting ``SPIDER_LOADER_WARN_ONLY = True``.
.. note:: This is a :ref:`pre-crawler setting <pre-crawler-settings>`.
.. setting:: SPIDER_MIDDLEWARES .. setting:: SPIDER_MIDDLEWARES
SPIDER_MIDDLEWARES SPIDER_MIDDLEWARES
@ -1978,6 +2054,8 @@ Example:
SPIDER_MODULES = ["mybot.spiders_prod", "mybot.spiders_dev"] SPIDER_MODULES = ["mybot.spiders_prod", "mybot.spiders_dev"]
.. note:: This is a :ref:`pre-crawler setting <pre-crawler-settings>`.
.. setting:: STATS_CLASS .. setting:: STATS_CLASS
STATS_CLASS STATS_CLASS
@ -2010,6 +2088,8 @@ Default: ``True`` (``False`` when :setting:`TWISTED_REACTOR_ENABLED` is ``False`
A boolean which specifies if the :ref:`telnet console <topics-telnetconsole>` A boolean which specifies if the :ref:`telnet console <topics-telnetconsole>`
will be enabled (provided its extension is also enabled). will be enabled (provided its extension is also enabled).
.. seealso:: :ref:`security-telnet`
.. setting:: TEMPLATES_DIR .. setting:: TEMPLATES_DIR
TEMPLATES_DIR TEMPLATES_DIR
@ -2049,7 +2129,7 @@ stopped) will not apply. This mode is currently experimental and may not be
suitable for production use. It may also not be supported by 3rd-party code. suitable for production use. It may also not be supported by 3rd-party code.
See :ref:`asyncio-without-reactor` for more information about this mode. See :ref:`asyncio-without-reactor` for more information about this mode.
.. note:: This setting can't be set :ref:`per-spider <spider-settings>`. .. note:: This is a :ref:`pre-crawler setting <pre-crawler-settings>`.
.. versionadded:: 2.15.0 .. versionadded:: 2.15.0
@ -2156,6 +2236,7 @@ current platform.
For additional information, see :doc:`core/howto/choosing-reactor`. For additional information, see :doc:`core/howto/choosing-reactor`.
.. note:: This is a :ref:`reactor setting <reactor-settings>`.
.. setting:: URLLENGTH_LIMIT .. setting:: URLLENGTH_LIMIT

View File

@ -355,6 +355,8 @@ Default: ``'scrapy.spidermiddlewares.referer.DefaultReferrerPolicy'``
using the special ``"referrer_policy"`` :ref:`Request.meta <topics-request-meta>` key, using the special ``"referrer_policy"`` :ref:`Request.meta <topics-request-meta>` key,
with the same acceptable values as for the ``REFERRER_POLICY`` setting. with the same acceptable values as for the ``REFERRER_POLICY`` setting.
.. seealso:: :ref:`security-credential-leakage`
Acceptable values for REFERRER_POLICY Acceptable values for REFERRER_POLICY
************************************* *************************************

View File

@ -29,6 +29,8 @@ disable it if you want. For more information about the extension itself see
.. note:: .. note::
This feature is not supported when :setting:`TWISTED_REACTOR_ENABLED` is ``False``. This feature is not supported when :setting:`TWISTED_REACTOR_ENABLED` is ``False``.
.. seealso:: :ref:`security-telnet`
.. highlight:: none .. highlight:: none
How to access the telnet console How to access the telnet console
@ -190,6 +192,8 @@ Default: ``'127.0.0.1'``
The interface the telnet console should listen on The interface the telnet console should listen on
.. seealso:: :ref:`security-telnet`
.. setting:: TELNETCONSOLE_USERNAME .. setting:: TELNETCONSOLE_USERNAME

View File

@ -231,6 +231,7 @@ disable = [
"undefined-variable", "undefined-variable",
"unused-argument", "unused-argument",
"unused-variable", "unused-variable",
"use-implicit-booleaness-not-comparison",
"useless-import-alias", # used as a hint to mypy "useless-import-alias", # used as a hint to mypy
"useless-return", # https://github.com/pylint-dev/pylint/issues/6530 "useless-return", # https://github.com/pylint-dev/pylint/issues/6530
"wrong-import-position", "wrong-import-position",
@ -274,6 +275,8 @@ markers = [
] ]
filterwarnings = [ filterwarnings = [
"ignore::DeprecationWarning:twisted.web.static", "ignore::DeprecationWarning:twisted.web.static",
# Twisted doesn't close failed sockets after CannotListenError: https://github.com/twisted/twisted/issues/6108
"ignore:Exception ignored in. <socket\\.socket.*laddr=..0\\.0\\.0\\.0., 0.:pytest.PytestUnraisableExceptionWarning",
] ]
[tool.ruff.lint] [tool.ruff.lint]

View File

@ -1,3 +1,4 @@
# pragma: no file cover
from scrapy.cmdline import execute from scrapy.cmdline import execute
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -64,7 +64,7 @@ class AddonManager:
:param settings: The :class:`~scrapy.settings.BaseSettings` object from \ :param settings: The :class:`~scrapy.settings.BaseSettings` object from \
which to read the early add-on configuration which to read the early add-on configuration
:type settings: :class:`~scrapy.settings.Settings` :type settings: :class:`~scrapy.settings.BaseSettings`
""" """
for clspath in build_component_list(settings["ADDONS"]): for clspath in build_component_list(settings["ADDONS"]):
addoncls = load_object(clspath) addoncls = load_object(clspath)

View File

@ -16,6 +16,8 @@ from twisted.python import failure
from scrapy.exceptions import ScrapyDeprecationWarning, UsageError from scrapy.exceptions import ScrapyDeprecationWarning, UsageError
from scrapy.utils.conf import arglist_to_dict, feed_process_params_from_cli from scrapy.utils.conf import arglist_to_dict, feed_process_params_from_cli
from scrapy.utils.deprecate import method_is_overridden
from scrapy.utils.python import global_object_name
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterable from collections.abc import Iterable
@ -36,6 +38,14 @@ class ScrapyCommand(ABC):
def __init__(self) -> None: def __init__(self) -> None:
self.settings: Settings | None = None # set in scrapy.cmdline self.settings: Settings | None = None # set in scrapy.cmdline
if method_is_overridden(self.__class__, ScrapyCommand, "help"):
warnings.warn(
"The ScrapyCommand.help() method is deprecated and overriding "
f"it, as the {global_object_name(self.__class__)} class does, "
"has no effect; override long_desc() instead.",
ScrapyDeprecationWarning,
stacklevel=2,
)
def set_crawler(self, crawler: Crawler) -> None: # pragma: no cover def set_crawler(self, crawler: Crawler) -> None: # pragma: no cover
warnings.warn( warnings.warn(
@ -63,15 +73,16 @@ class ScrapyCommand(ABC):
def long_desc(self) -> str: def long_desc(self) -> str:
"""A long description of the command. Return short description when not """A long description of the command. Return short description when not
available. It cannot contain newlines since contents will be formatted available. It cannot contain newlines since contents will be formatted
by optparser which removes newlines and wraps text. by argparse which removes newlines and wraps text.
""" """
return self.short_desc() return self.short_desc()
def help(self) -> str: def help(self) -> str:
"""An extensive help for the command. It will be shown when using the warnings.warn(
"help" command. It can contain newlines since no post-formatting will "ScrapyCommand.help() is deprecated, use long_desc() instead.",
be applied to its contents. ScrapyDeprecationWarning,
""" stacklevel=2,
)
return self.long_desc() return self.long_desc()
def add_options(self, parser: argparse.ArgumentParser) -> None: def add_options(self, parser: argparse.ArgumentParser) -> None:

View File

@ -1,12 +1,29 @@
import argparse from __future__ import annotations
import os import os
import shlex
import subprocess
import sys import sys
from typing import Any, ClassVar from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar
from scrapy.commands import ScrapyCommand from scrapy.commands import ScrapyCommand
from scrapy.exceptions import UsageError from scrapy.exceptions import UsageError
from scrapy.spiderloader import get_spider_loader from scrapy.spiderloader import get_spider_loader
if TYPE_CHECKING:
import argparse
def _edit_file(editor: str, file_path: str | os.PathLike[str]) -> int:
"""Open ``file_path`` with ``editor`` and return the editor exit code.
``editor`` may include arguments (e.g. ``"code -w"``); it is split with
:func:`shlex.split` and the file is passed as a separate argument, so no
shell is involved.
"""
return subprocess.call([*shlex.split(editor), os.fspath(file_path)]) # noqa: S603
class Command(ScrapyCommand): class Command(ScrapyCommand):
requires_project = True requires_project = True
@ -45,4 +62,4 @@ class Command(ScrapyCommand):
sfile = sys.modules[spidercls.__module__].__file__ sfile = sys.modules[spidercls.__module__].__file__
assert sfile assert sfile
sfile = sfile.replace(".pyc", ".py") sfile = sfile.replace(".pyc", ".py")
self.exitcode = os.system(f'{editor} "{sfile}"') # noqa: S605 self.exitcode = _edit_file(editor, Path(sfile))

View File

@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import os
import shutil import shutil
import string import string
from importlib import import_module from importlib import import_module
@ -10,12 +9,14 @@ from urllib.parse import urlparse
import scrapy import scrapy
from scrapy.commands import ScrapyCommand from scrapy.commands import ScrapyCommand
from scrapy.commands.edit import _edit_file
from scrapy.exceptions import UsageError from scrapy.exceptions import UsageError
from scrapy.spiderloader import get_spider_loader from scrapy.spiderloader import get_spider_loader
from scrapy.utils.template import render_templatefile, string_camelcase from scrapy.utils.template import render_templatefile, string_camelcase
if TYPE_CHECKING: if TYPE_CHECKING:
import argparse import argparse
import os
def sanitize_module_name(module_name: str) -> str: def sanitize_module_name(module_name: str) -> str:
@ -118,9 +119,11 @@ class Command(ScrapyCommand):
template_file = self._find_template(opts.template) template_file = self._find_template(opts.template)
if template_file: if template_file:
self._genspider(module, name, url, opts.template, template_file) spider_file = self._genspider(
module, name, url, opts.template, template_file
)
if opts.edit: if opts.edit:
self.exitcode = os.system(f'scrapy edit "{name}"') # noqa: S605 self.exitcode = _edit_file(self.settings["EDITOR"], spider_file)
def _generate_template_variables( def _generate_template_variables(
self, self,
@ -148,7 +151,7 @@ class Command(ScrapyCommand):
url: str, url: str,
template_name: str, template_name: str,
template_file: str | os.PathLike[str], template_file: str | os.PathLike[str],
) -> None: ) -> Path:
"""Generate the spider module, based on the given template""" """Generate the spider module, based on the given template"""
assert self.settings is not None assert self.settings is not None
tvars = self._generate_template_variables(module, name, url, template_name) tvars = self._generate_template_variables(module, name, url, template_name)
@ -168,6 +171,7 @@ class Command(ScrapyCommand):
) )
if spiders_module: if spiders_module:
print(f"in module:\n {spiders_module.__name__}.{module}") print(f"in module:\n {spiders_module.__name__}.{module}")
return Path(spider_file)
def _find_template(self, template: str) -> Path | None: def _find_template(self, template: str) -> Path | None:
template_file = Path(self.templates_dir, f"{template}.tmpl") template_file = Path(self.templates_dir, f"{template}.tmpl")

View File

@ -51,6 +51,8 @@ class Contract:
results.addSuccess(self.testcase_pre) results.addSuccess(self.testcase_pre)
cb_result = cb(response, **cb_kwargs) cb_result = cb(response, **cb_kwargs)
if isinstance(cb_result, (AsyncGenerator, CoroutineType)): if isinstance(cb_result, (AsyncGenerator, CoroutineType)):
if isinstance(cb_result, CoroutineType):
cb_result.close()
raise TypeError("Contracts don't support async callbacks") raise TypeError("Contracts don't support async callbacks")
return list(cast("Iterable[Any]", iterate_spider_output(cb_result))) return list(cast("Iterable[Any]", iterate_spider_output(cb_result)))
@ -67,6 +69,8 @@ class Contract:
def wrapper(response: Response, **cb_kwargs: Any) -> list[Any]: def wrapper(response: Response, **cb_kwargs: Any) -> list[Any]:
cb_result = cb(response, **cb_kwargs) cb_result = cb(response, **cb_kwargs)
if isinstance(cb_result, (AsyncGenerator, CoroutineType)): if isinstance(cb_result, (AsyncGenerator, CoroutineType)):
if isinstance(cb_result, CoroutineType):
cb_result.close()
raise TypeError("Contracts don't support async callbacks") raise TypeError("Contracts don't support async callbacks")
output = list(cast("Iterable[Any]", iterate_spider_output(cb_result))) output = list(cast("Iterable[Any]", iterate_spider_output(cb_result)))
try: try:

View File

@ -17,7 +17,6 @@ from zope.interface.verify import verifyObject
from scrapy.core.downloader.tls import ( from scrapy.core.downloader.tls import (
_TWISTED_VERSION_MAP, _TWISTED_VERSION_MAP,
DEFAULT_CIPHERS,
_openssl_methods, _openssl_methods,
_ScrapyClientTLSOptions, _ScrapyClientTLSOptions,
_ScrapyClientTLSOptions26, _ScrapyClientTLSOptions26,
@ -48,8 +47,13 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS):
instance. instance.
The purpose of this custom class is to provide a ``creatorForNetloc()`` The purpose of this custom class is to provide a ``creatorForNetloc()``
method that returns a ``_ScrapyClientTLSOptions`` instance configured based method that returns:
on TLS settings provided to the factory.
- a ``_ScrapyClientTLSOptions26`` or ``_ScrapyClientTLSOptions`` instance
configured based on TLS settings provided to the factory (when the
certificate verification is disabled);
- a result of ``optionsForClientTLS()`` called with those TLS settings
(when the certificate verification is enabled).
""" """
def __init__( def __init__(
@ -68,11 +72,11 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS):
self.tls_min_version: TLSVersion | None = tls_min_version self.tls_min_version: TLSVersion | None = tls_min_version
self.tls_max_version: TLSVersion | None = tls_max_version self.tls_max_version: TLSVersion | None = tls_max_version
self.tls_verbose_logging: bool = tls_verbose_logging # unused self.tls_verbose_logging: bool = tls_verbose_logging # unused
self.tls_ciphers: AcceptableCiphers self.tls_ciphers: AcceptableCiphers | None = (
if tls_ciphers: AcceptableCiphers.fromOpenSSLCipherString(tls_ciphers)
self.tls_ciphers = AcceptableCiphers.fromOpenSSLCipherString(tls_ciphers) if tls_ciphers
else: else None
self.tls_ciphers = DEFAULT_CIPHERS )
self._verify_certificates = verify_certificates self._verify_certificates = verify_certificates
@classmethod @classmethod

View File

@ -241,6 +241,16 @@ class BaseStreamingDownloadHandler(BaseHttpDownloadHandler, ABC, Generic[_Respon
body=response_body.getvalue(), body=response_body.getvalue(),
) )
@staticmethod
def _request_headers(request: Request) -> Headers:
"""Get a prepared copy of the request headers.
This removes the Proxy-Authorization header.
"""
headers = request.headers.copy()
headers.pop(b"Proxy-Authorization", None)
return headers
def _get_bind_address_host(self) -> str | None: def _get_bind_address_host(self) -> str | None:
"""Return the host portion of the bind address. """Return the host portion of the bind address.
@ -279,10 +289,8 @@ class BaseStreamingDownloadHandler(BaseHttpDownloadHandler, ABC, Generic[_Respon
if not proxy: if not proxy:
return None, None return None, None
proxy = add_http_if_no_scheme(proxy) proxy = add_http_if_no_scheme(proxy)
auth_header: list[bytes] | None = request.headers.pop( auth_header: bytes | None = request.headers.get(b"Proxy-Authorization")
b"Proxy-Authorization", None return proxy, auth_header.decode("ascii") if auth_header else None
)
return proxy, auth_header[0].decode("ascii") if auth_header else None
def _extract_proxy_url_with_creds(self, request: Request) -> str | None: def _extract_proxy_url_with_creds(self, request: Request) -> str | None:
"""Return the proxy URL with the userinfo added based on the """Return the proxy URL with the userinfo added based on the

View File

@ -152,13 +152,14 @@ class HttpxDownloadHandler(_Base):
f"SOCKS proxy support in {type(self).__name__} requires the 'httpx[socks]' extra to be installed." f"SOCKS proxy support in {type(self).__name__} requires the 'httpx[socks]' extra to be installed."
) )
client = self._get_client(proxy) client = self._get_client(proxy)
headers = self._request_headers(request).to_tuple_list()
try: try:
async with client.stream( async with client.stream(
request.method, request.method,
request.url, request.url,
content=request.body, content=request.body,
headers=request.headers.to_tuple_list(), headers=headers,
timeout=timeout, timeout=timeout,
) as response: ) as response:
yield response yield response

View File

@ -2,9 +2,9 @@
An asynchronous FTP file download handler for scrapy which somehow emulates an http response. An asynchronous FTP file download handler for scrapy which somehow emulates an http response.
FTP connection parameters are passed using the request meta field: FTP connection parameters are passed using the request meta field:
- ftp_user (required) - ftp_user (optional, falls back to FTP_USER)
- ftp_password (required) - ftp_password (optional, falls back to FTP_PASSWORD)
- ftp_passive (by default, enabled) sets FTP connection passive mode - ftp_passive (optional, falls back to FTP_PASSIVE_MODE) sets FTP connection passive mode
- ftp_local_filename - ftp_local_filename
- If not given, file data will come in the response.body, as a normal scrapy Response, - If not given, file data will come in the response.body, as a normal scrapy Response,
which will imply that the entire file will be on memory. which will imply that the entire file will be on memory.
@ -119,7 +119,10 @@ class FTPDownloadHandler(BaseDownloadHandler):
httpcode = self.CODE_MAPPING.get(ftpcode, self.CODE_MAPPING["default"]) httpcode = self.CODE_MAPPING.get(ftpcode, self.CODE_MAPPING["default"])
return Response(url=request.url, status=httpcode, body=message.encode()) return Response(url=request.url, status=httpcode, body=message.encode())
raise raise
protocol.close() finally:
protocol.close()
assert client.transport
client.transport.loseConnection()
headers = {"local filename": protocol.filename or b"", "size": protocol.size} headers = {"local filename": protocol.filename or b"", "size": protocol.size}
body = protocol.filename or protocol.body.read() body = protocol.filename or protocol.body.read()
respcls = responsetypes.from_args(url=request.url, body=body) respcls = responsetypes.from_args(url=request.url, body=body)

View File

@ -104,7 +104,6 @@ class HTTP11DownloadHandler(BaseHttpDownloadHandler):
self._disconnect_timeout: int = 1 self._disconnect_timeout: int = 1
async def download_request(self, request: Request) -> Response: async def download_request(self, request: Request) -> Response:
"""Return a deferred for the HTTP download"""
if hasattr(self._crawler.spider, "download_maxsize"): # pragma: no cover if hasattr(self._crawler.spider, "download_maxsize"): # pragma: no cover
warn_on_deprecated_spider_attribute("download_maxsize", "DOWNLOAD_MAXSIZE") warn_on_deprecated_spider_attribute("download_maxsize", "DOWNLOAD_MAXSIZE")
if hasattr(self._crawler.spider, "download_warnsize"): # pragma: no cover if hasattr(self._crawler.spider, "download_warnsize"): # pragma: no cover
@ -283,7 +282,7 @@ def _tunnel_request_data(
class _TunnelingAgent(Agent): class _TunnelingAgent(Agent):
"""An agent that uses a L{TunnelingTCP4ClientEndpoint} to make HTTPS """An agent that uses a ``_TunnelingTCP4ClientEndpoint`` to make HTTPS
downloads. It may look strange that we have chosen to subclass Agent and not downloads. It may look strange that we have chosen to subclass Agent and not
ProxyAgent but consider that after the tunnel is opened the proxy is ProxyAgent but consider that after the tunnel is opened the proxy is
transparent to the client; thus the agent should behave like there is no transparent to the client; thus the agent should behave like there is no
@ -545,7 +544,8 @@ class _ScrapyAgent:
expected_size, maxsize, request, expected=True expected_size, maxsize, request, expected=True
) )
logger.warning(warning_msg) logger.warning(warning_msg)
txresponse._transport.loseConnection() # Abort connection immediately.
txresponse._transport._producer.abortConnection()
raise DownloadCancelledError(warning_msg) raise DownloadCancelledError(warning_msg)
if warnsize and expected_size > warnsize: if warnsize and expected_size > warnsize:

View File

@ -43,6 +43,13 @@ _openssl_methods: dict[str, int] = {
def __getattr__(name: str) -> Any: def __getattr__(name: str) -> Any:
if name == "DEFAULT_CIPHERS":
warnings.warn(
"scrapy.core.downloader.tls.DEFAULT_CIPHERS is deprecated.",
ScrapyDeprecationWarning,
stacklevel=2,
)
return AcceptableCiphers.fromOpenSSLCipherString("DEFAULT")
deprecated = { deprecated = {
"METHOD_TLS": "TLS", "METHOD_TLS": "TLS",
"METHOD_TLSv10": "TLSv1.0", "METHOD_TLSv10": "TLSv1.0",
@ -177,8 +184,3 @@ class _ScrapyClientTLSOptions26(ClientTLSOptions):
return True return True
return verifyCallback return verifyCallback
DEFAULT_CIPHERS: AcceptableCiphers = AcceptableCiphers.fromOpenSSLCipherString(
"DEFAULT"
)

View File

@ -114,11 +114,7 @@ class H2ConnectionPool:
d.errback(ResponseFailed(errors)) d.errback(ResponseFailed(errors))
def close_connections(self) -> None: def close_connections(self) -> None:
"""Close all the HTTP/2 connections and remove them from pool """Close all the HTTP/2 connections and remove them from pool."""
Returns:
Deferred that fires when all connections have been closed
"""
for conn in self._connections.values(): for conn in self._connections.values():
assert conn.transport is not None # typing assert conn.transport is not None # typing
conn.transport.abortConnection() conn.transport.abortConnection()

View File

@ -101,7 +101,7 @@ class H2ClientProtocol(Protocol, TimeoutMixin):
uri is used to verify that incoming client requests have correct uri is used to verify that incoming client requests have correct
base URL. base URL.
settings -- Scrapy project settings settings -- Scrapy project settings
conn_lost_deferred -- Deferred fires with the reason: Failure to notify conn_lost_deferred -- Deferred that fires with the list of underlying exceptions to notify
that connection was lost that connection was lost
tls_verbose_logging -- Whether to log TLS details tls_verbose_logging -- Whether to log TLS details
""" """
@ -375,7 +375,7 @@ class H2ClientProtocol(Protocol, TimeoutMixin):
def _handle_events(self, events: list[Event]) -> None: def _handle_events(self, events: list[Event]) -> None:
"""Private method which acts as a bridge between the events """Private method which acts as a bridge between the events
received from the HTTP/2 data and IH2EventsHandler received from the HTTP/2 data and the handlers in this class.
Arguments: Arguments:
events -- A list of events that the remote peer triggered by sending data events -- A list of events that the remote peer triggered by sending data

View File

@ -131,10 +131,10 @@ class Scheduler(BaseScheduler):
(:setting:`SCHEDULER_PRIORITY_QUEUE`) that sort requests by (:setting:`SCHEDULER_PRIORITY_QUEUE`) that sort requests by
:attr:`~scrapy.http.Request.priority`. :attr:`~scrapy.http.Request.priority`.
By default, a single, memory-based priority queue is used for all requests. By default, memory-based priority queues are used for all requests.
When using :setting:`JOBDIR`, a disk-based priority queue is also created, When using :setting:`JOBDIR`, disk-based priority queues are also created,
and only unserializable requests are stored in the memory-based priority and only unserializable requests are stored in the memory-based priority
queue. For a given priority value, requests in memory take precedence over queues. For a given priority value, requests in memory take precedence over
requests in disk. requests in disk.
Each priority queue stores requests in separate internal queues, one per Each priority queue stores requests in separate internal queues, one per
@ -209,8 +209,8 @@ class Scheduler(BaseScheduler):
------------------------- -------------------------
While pending requests are below the configured values of While pending requests are below the configured values of
:setting:`CONCURRENT_REQUESTS`, :setting:`CONCURRENT_REQUESTS_PER_DOMAIN` :setting:`CONCURRENT_REQUESTS` or
or :setting:`CONCURRENT_REQUESTS_PER_IP`, those requests are sent :setting:`CONCURRENT_REQUESTS_PER_DOMAIN`, those requests are sent
concurrently. concurrently.
As a result, the first few requests of a crawl may not follow the desired As a result, the first few requests of a crawl may not follow the desired
@ -342,7 +342,7 @@ class Scheduler(BaseScheduler):
def open(self, spider: Spider) -> Deferred[None] | None: def open(self, spider: Spider) -> Deferred[None] | None:
""" """
(1) initialize the memory queue (1) initialize the memory queue
(2) initialize the disk queue if the ``jobdir`` attribute is a valid directory (2) initialize the disk queue if the ``jobdir`` argument wasn't empty
(3) return the result of the dupefilter's ``open`` method (3) return the result of the dupefilter's ``open`` method
""" """
self.spider: Spider = spider self.spider: Spider = spider

View File

@ -441,7 +441,7 @@ class Scraper:
self, output: Any, response: Response | Failure self, output: Any, response: Response | Failure
) -> Deferred[None]: ) -> Deferred[None]:
"""Process each Request/Item (given in the output parameter) returned """Process each Request/Item (given in the output parameter) returned
from the given spider. from the spider.
Items are sent to the item pipelines, requests are scheduled. Items are sent to the item pipelines, requests are scheduled.
""" """
@ -451,7 +451,7 @@ class Scraper:
self, output: Any, response: Response | Failure self, output: Any, response: Response | Failure
) -> None: ) -> None:
"""Process each Request/Item (given in the output parameter) returned """Process each Request/Item (given in the output parameter) returned
from the given spider. from the spider.
Items are sent to the item pipelines, requests are scheduled. Items are sent to the item pipelines, requests are scheduled.
""" """

View File

@ -363,9 +363,12 @@ class CrawlerRunnerBase(ABC):
""" """
Return a :class:`~scrapy.crawler.Crawler` 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 Crawler, the runner's settings are
merged into it as defaults: for each setting, the runner's value
is applied only if the Crawler does not already have that setting at
an equal or higher priority. The Crawler is then returned.
* If ``crawler_or_spidercls`` is a Spider subclass, a new Crawler * If ``crawler_or_spidercls`` is a Spider subclass, a new Crawler
is constructed for it. is constructed for it using this runner's settings.
* 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), a spider with this name in a Scrapy project (using spider loader),
then creates a Crawler instance for it. then creates a Crawler instance for it.
@ -376,6 +379,7 @@ class CrawlerRunnerBase(ABC):
"it must be a spider class (or a Crawler object)" "it must be a spider class (or a Crawler object)"
) )
if isinstance(crawler_or_spidercls, Crawler): if isinstance(crawler_or_spidercls, Crawler):
crawler_or_spidercls.settings.update(self.settings)
return crawler_or_spidercls return crawler_or_spidercls
return self._create_crawler(crawler_or_spidercls) return self._create_crawler(crawler_or_spidercls)
@ -527,8 +531,8 @@ class AsyncCrawlerRunner(CrawlerRunnerBase):
""" """
Run a crawler with the provided arguments. Run a crawler with the provided arguments.
It will call the given Crawler's :meth:`~Crawler.crawl` method, while It will call the given Crawler's :meth:`~Crawler.crawl_async` method,
keeping track of it so it can be stopped later. 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 instance, this method will try to create one using this parameter as
@ -769,7 +773,7 @@ class CrawlerProcess(CrawlerProcessBase, CrawlerRunner):
""" """
This method starts a :mod:`~twisted.internet.reactor`, adjusts its pool This method starts a :mod:`~twisted.internet.reactor`, adjusts its pool
size to :setting:`REACTOR_THREADPOOL_MAXSIZE`, and installs a DNS size to :setting:`REACTOR_THREADPOOL_MAXSIZE`, and installs a DNS
resolver based on :setting:`DNSCACHE_ENABLED`. resolver based on :setting:`TWISTED_DNS_RESOLVER`.
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`. crawlers have finished, using :meth:`join`.
@ -871,10 +875,10 @@ class AsyncCrawlerProcess(CrawlerProcessBase, AsyncCrawlerRunner):
When using a reactor it adjusts its pool size to When using a reactor it adjusts its pool size to
:setting:`REACTOR_THREADPOOL_MAXSIZE` and installs a DNS resolver based :setting:`REACTOR_THREADPOOL_MAXSIZE` and installs a DNS resolver based
on :setting:`DNSCACHE_ENABLED`. on :setting:`TWISTED_DNS_RESOLVER`.
If ``stop_after_crawl`` is True, the reactor will be stopped after all If ``stop_after_crawl`` is True, the reactor/event loop will be stopped
crawlers have finished, using :meth:`join`. after all crawlers have finished, using :meth:`join`.
:param bool stop_after_crawl: stop or not the reactor when all :param bool stop_after_crawl: stop or not the reactor when all
crawlers have finished crawlers have finished

View File

@ -12,6 +12,7 @@ from scrapy.http.cookies import CookieJar
from scrapy.utils.decorators import _warn_spider_arg from scrapy.utils.decorators import _warn_spider_arg
from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.python import to_unicode from scrapy.utils.python import to_unicode
from scrapy.utils.request import _decode_cookie, _to_verbose_cookies
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterable, Sequence from collections.abc import Iterable, Sequence
@ -134,29 +135,10 @@ class CookiesMiddleware:
Given a dict consisting of cookie components, return its string representation. Given a dict consisting of cookie components, return its string representation.
Decode from bytes if necessary. Decode from bytes if necessary.
""" """
decoded = {} decoded = _decode_cookie(cookie, request)
if decoded is None:
return None
flags = set() flags = set()
for key in ("name", "value", "path", "domain"):
value = cookie.get(key)
if value is None:
if key in {"name", "value"}:
msg = f"Invalid cookie found in request {request}: {cookie} ('{key}' is missing)"
logger.warning(msg)
return None
continue
if isinstance(value, (bool, float, int, str)):
decoded[key] = str(value)
else:
assert isinstance(value, bytes)
try:
decoded[key] = value.decode("utf8")
except UnicodeDecodeError:
logger.warning(
"Non UTF-8 encoded cookie found in request %s: %s",
request,
cookie,
)
decoded[key] = value.decode("latin1", errors="replace")
for flag in ("secure",): for flag in ("secure",):
value = cookie.get(flag, _UNSET) value = cookie.get(flag, _UNSET)
if value is _UNSET or not value: if value is _UNSET or not value:
@ -177,11 +159,7 @@ class CookiesMiddleware:
""" """
if not request.cookies: if not request.cookies:
return () return ()
cookies: Iterable[VerboseCookie] cookies: Iterable[VerboseCookie] = _to_verbose_cookies(request.cookies)
if isinstance(request.cookies, dict):
cookies = tuple({"name": k, "value": v} for k, v in request.cookies.items())
else:
cookies = request.cookies
for cookie in cookies: for cookie in cookies:
cookie.setdefault("secure", urlparse_cached(request).scheme == "https") cookie.setdefault("secure", urlparse_cached(request).scheme == "https")
formatted = filter(None, (self._format_cookie(c, request) for c in cookies)) formatted = filter(None, (self._format_cookie(c, request) for c in cookies))

View File

@ -6,7 +6,7 @@ from logging import getLogger
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from scrapy import Request, Spider, signals from scrapy import Request, Spider, signals
from scrapy.exceptions import IgnoreRequest, NotConfigured from scrapy.exceptions import IgnoreRequest, NotConfigured, ScrapyDeprecationWarning
from scrapy.http import Response, TextResponse from scrapy.http import Response, TextResponse
from scrapy.responsetypes import responsetypes from scrapy.responsetypes import responsetypes
from scrapy.utils._compression import ( from scrapy.utils._compression import (
@ -60,7 +60,7 @@ else:
class HttpCompressionMiddleware: class HttpCompressionMiddleware:
"""This middleware allows compressed (gzip, deflate) traffic to be """This middleware allows compressed (gzip, deflate etc.) traffic to be
sent/received from websites""" sent/received from websites"""
def __init__( def __init__(
@ -70,6 +70,12 @@ class HttpCompressionMiddleware:
crawler: Crawler | None = None, crawler: Crawler | None = None,
): ):
if not crawler: if not crawler:
warnings.warn(
"Instantiating HttpCompressionMiddleware without a 'crawler' "
"argument is deprecated.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
self.stats = stats self.stats = stats
self._max_size = 1073741824 self._max_size = 1073741824
self._warn_size = 33554432 self._warn_size = 33554432
@ -108,49 +114,47 @@ class HttpCompressionMiddleware:
) -> Request | Response: ) -> Request | Response:
if request.method == "HEAD": if request.method == "HEAD":
return response return response
if isinstance(response, Response): content_encoding = response.headers.getlist("Content-Encoding")
content_encoding = response.headers.getlist("Content-Encoding") if content_encoding:
if content_encoding: max_size = request.meta.get("download_maxsize", self._max_size)
max_size = request.meta.get("download_maxsize", self._max_size) warn_size = request.meta.get("download_warnsize", self._warn_size)
warn_size = request.meta.get("download_warnsize", self._warn_size) try:
try: decoded_body, content_encoding = self._handle_encoding(
decoded_body, content_encoding = self._handle_encoding( response.body, content_encoding, max_size
response.body, content_encoding, max_size
)
except _DecompressionMaxSizeExceeded as e:
raise IgnoreRequest(
f"Ignored response {response} because its body "
f"({len(response.body)} B compressed, "
f"{e.decompressed_size} B decompressed so far) exceeded "
f"DOWNLOAD_MAXSIZE ({max_size} B) during decompression."
) from e
if len(response.body) < warn_size <= len(decoded_body):
logger.warning(
f"{response} body size after decompression "
f"({len(decoded_body)} B) is larger than the "
f"download warning size ({warn_size} B)."
)
if content_encoding:
self._warn_unknown_encoding(response, content_encoding)
response.headers["Content-Encoding"] = content_encoding
if self.stats:
self.stats.inc_value(
"httpcompression/response_bytes",
len(decoded_body),
)
self.stats.inc_value("httpcompression/response_count")
respcls = responsetypes.from_args(
headers=response.headers, url=response.url, body=decoded_body
) )
kwargs: dict[str, Any] = {"body": decoded_body} except _DecompressionMaxSizeExceeded as e:
if issubclass(respcls, TextResponse): raise IgnoreRequest(
# force recalculating the encoding until we make sure the f"Ignored response {response} because its body "
# responsetypes guessing is reliable f"({len(response.body)} B compressed, "
kwargs["encoding"] = None f"{e.decompressed_size} B decompressed so far) exceeded "
response = response.replace(cls=respcls, **kwargs) f"DOWNLOAD_MAXSIZE ({max_size} B) during decompression."
if not content_encoding: ) from e
del response.headers["Content-Encoding"] if len(response.body) < warn_size <= len(decoded_body):
logger.warning(
f"{response} body size after decompression "
f"({len(decoded_body)} B) is larger than the "
f"download warning size ({warn_size} B)."
)
if content_encoding:
self._warn_unknown_encoding(response, content_encoding)
response.headers["Content-Encoding"] = content_encoding
if self.stats:
self.stats.inc_value(
"httpcompression/response_bytes",
len(decoded_body),
)
self.stats.inc_value("httpcompression/response_count")
respcls = responsetypes.from_args(
headers=response.headers, url=response.url, body=decoded_body
)
kwargs: dict[str, Any] = {"body": decoded_body}
if issubclass(respcls, TextResponse):
# force recalculating the encoding until we make sure the
# responsetypes guessing is reliable
kwargs["encoding"] = None
response = response.replace(cls=respcls, **kwargs)
if not content_encoding:
del response.headers["Content-Encoding"]
return response return response
def _handle_encoding( def _handle_encoding(

View File

@ -2,7 +2,6 @@ from __future__ import annotations
import logging import logging
import re import re
import warnings
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from scrapy import Request, Spider, signals from scrapy import Request, Spider, signals
@ -75,25 +74,10 @@ class OffsiteMiddleware:
allowed_domains = getattr(spider, "allowed_domains", None) allowed_domains = getattr(spider, "allowed_domains", None)
if not allowed_domains: if not allowed_domains:
return re.compile("") # allow all by default return re.compile("") # allow all by default
url_pattern = re.compile(r"^https?://.*$")
port_pattern = re.compile(r":\d+$")
domains = [] domains = []
for domain in allowed_domains: for domain in allowed_domains:
if domain is None: if domain is None:
continue continue
if url_pattern.match(domain): domains.append(re.escape(domain))
message = (
"allowed_domains accepts only domains, not URLs. "
f"Ignoring URL entry {domain} in allowed_domains."
)
warnings.warn(message, stacklevel=2)
elif port_pattern.search(domain):
message = (
"allowed_domains accepts only domains without ports. "
f"Ignoring entry {domain} in allowed_domains."
)
warnings.warn(message, stacklevel=2)
else:
domains.append(re.escape(domain))
regex = rf"^(.*\.)?({'|'.join(domains)})$" regex = rf"^(.*\.)?({'|'.join(domains)})$"
return re.compile(regex) return re.compile(regex)

View File

@ -196,10 +196,7 @@ class BaseRedirectMiddleware:
class RedirectMiddleware(BaseRedirectMiddleware): class RedirectMiddleware(BaseRedirectMiddleware):
""" """Handle redirection of requests based on response status."""
Handle redirection of requests based on response status
and meta-refresh html tag.
"""
@_warn_spider_arg @_warn_spider_arg
def process_response( def process_response(
@ -251,6 +248,8 @@ class RedirectMiddleware(BaseRedirectMiddleware):
class MetaRefreshMiddleware(BaseRedirectMiddleware): class MetaRefreshMiddleware(BaseRedirectMiddleware):
"""Handle redirection of requests based on meta-refresh html tag."""
enabled_setting = "METAREFRESH_ENABLED" enabled_setting = "METAREFRESH_ENABLED"
def __init__(self, settings: BaseSettings): def __init__(self, settings: BaseSettings):

View File

@ -5,9 +5,6 @@ problems such as a connection timeout or HTTP 500 error.
You can change the behaviour of this middleware by modifying the scraping settings: You can change the behaviour of this middleware by modifying the scraping settings:
RETRY_TIMES - how many times to retry a failed page RETRY_TIMES - how many times to retry a failed page
RETRY_HTTP_CODES - which HTTP response codes to retry 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.
""" """
from __future__ import annotations from __future__ import annotations
@ -70,8 +67,9 @@ def get_retry_request(
and :ref:`stats <topics-stats>`, and to provide extra logging context (see and :ref:`stats <topics-stats>`, and to provide extra logging context (see
:func:`logging.debug`). :func:`logging.debug`).
*reason* is a string or an :class:`Exception` object that indicates the *reason* is a string, an :class:`Exception` subclass or an
reason why the request needs to be retried. It is used to name retry stats. :class:`Exception` object that indicates the reason why the request needs
to be retried. It is used to name retry stats.
*max_retry_times* is a number that determines the maximum number of times *max_retry_times* is a number that determines the maximum number of times
that *request* can be retried. If not specified or ``None``, the number is that *request* can be retried. If not specified or ``None``, the number is

View File

@ -115,7 +115,7 @@ class UsageError(Exception):
class ScrapyDeprecationWarning(Warning): class ScrapyDeprecationWarning(Warning):
"""Warning category for deprecated features, since the default """Warning category for deprecated features, since the default
DeprecationWarning is silenced on Python 2.7+ :exc:`DeprecationWarning` is silenced.
""" """

View File

@ -171,7 +171,15 @@ class XmlItemExporter(BaseItemExporter):
super().__init__(**kwargs) super().__init__(**kwargs)
if not self.encoding: if not self.encoding:
self.encoding = "utf-8" self.encoding = "utf-8"
self.xg = XMLGenerator(file, encoding=self.encoding) # copied from xml.sax.saxutils._gettextwriter()
self.stream = TextIOWrapper(
file,
encoding=self.encoding,
errors="xmlcharrefreplace",
newline="\n",
write_through=True,
)
self.xg = XMLGenerator(self.stream, encoding=self.encoding)
def _beautify_newline(self, new_item: bool = False) -> None: def _beautify_newline(self, new_item: bool = False) -> None:
if self.indent is not None and (self.indent > 0 or new_item): if self.indent is not None and (self.indent > 0 or new_item):
@ -199,6 +207,7 @@ class XmlItemExporter(BaseItemExporter):
def finish_exporting(self) -> None: def finish_exporting(self) -> None:
self.xg.endElement(self.root_element) self.xg.endElement(self.root_element)
self.xg.endDocument() self.xg.endDocument()
self.stream.detach() # Avoid closing the wrapped file.
def _export_xml_field(self, name: str, serialized_value: Any, depth: int) -> None: def _export_xml_field(self, name: str, serialized_value: Any, depth: int) -> None:
self._beautify_indent(depth=depth) self._beautify_indent(depth=depth)

View File

@ -250,20 +250,22 @@ class S3FeedStorage(BlockingFeedStorage):
def _store_in_thread(self, file: IO[bytes]) -> None: def _store_in_thread(self, file: IO[bytes]) -> None:
file.seek(0) file.seek(0)
if self.acl: try:
self.s3_client.upload_fileobj( if self.acl:
Bucket=self.bucketname, self.s3_client.upload_fileobj(
Key=self.keyname, Bucket=self.bucketname,
Fileobj=file, Key=self.keyname,
ExtraArgs={"ACL": self.acl}, Fileobj=file,
) ExtraArgs={"ACL": self.acl},
else: )
self.s3_client.upload_fileobj( else:
Bucket=self.bucketname, self.s3_client.upload_fileobj(
Key=self.keyname, Bucket=self.bucketname,
Fileobj=file, Key=self.keyname,
) Fileobj=file,
file.close() )
finally:
file.close()
class GCSFeedStorage(BlockingFeedStorage): class GCSFeedStorage(BlockingFeedStorage):

View File

@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
class LogStats: class LogStats:
"""Log basic scraping stats periodically like: """Log basic scraping stats periodically like:
* RPM - Requests per Minute * RPM - Responses per Minute
* IPM - Items per Minute * IPM - Items per Minute
""" """

View File

@ -113,7 +113,7 @@ class MemoryUsage:
{"memusage": mem}, {"memusage": mem},
extra={"crawler": self.crawler}, extra={"crawler": self.crawler},
) )
if self.notify_mails: if self.notify_mails: # pragma: no cover
subj = ( subj = (
f"{self.crawler.settings['BOT_NAME']} terminated: " f"{self.crawler.settings['BOT_NAME']} terminated: "
f"memory usage exceeded {mem}MiB at {socket.gethostname()}" f"memory usage exceeded {mem}MiB at {socket.gethostname()}"
@ -146,7 +146,7 @@ class MemoryUsage:
{"memusage": mem}, {"memusage": mem},
extra={"crawler": self.crawler}, extra={"crawler": self.crawler},
) )
if self.notify_mails: if self.notify_mails: # pragma: no cover
subj = ( subj = (
f"{self.crawler.settings['BOT_NAME']} warning: " f"{self.crawler.settings['BOT_NAME']} warning: "
f"memory usage reached {mem}MiB at {socket.gethostname()}" f"memory usage reached {mem}MiB at {socket.gethostname()}"
@ -155,7 +155,7 @@ class MemoryUsage:
self.crawler.stats.set_value("memusage/warning_notified", 1) self.crawler.stats.set_value("memusage/warning_notified", 1)
self.warned = True self.warned = True
def _send_report(self, rcpts: list[str], subject: str) -> None: def _send_report(self, rcpts: list[str], subject: str) -> None: # pragma: no cover
"""send notification mail with some additional useful info""" """send notification mail with some additional useful info"""
assert self.crawler.engine assert self.crawler.engine
assert self.crawler.stats assert self.crawler.stats

View File

@ -116,7 +116,7 @@ class AutoThrottle:
# It works better with problematic sites. # It works better with problematic sites.
new_delay = max(target_delay, new_delay) new_delay = max(target_delay, new_delay)
# Make sure self.mindelay <= new_delay <= self.max_delay # Make sure self.mindelay <= new_delay <= self.maxdelay
new_delay = min(max(self.mindelay, new_delay), self.maxdelay) new_delay = min(max(self.mindelay, new_delay), self.maxdelay)
# Dont adjust delay if response status != 200 and new delay is smaller # Dont adjust delay if response status != 200 and new delay is smaller

View File

@ -5,11 +5,9 @@ Use this module (instead of the more specific ones) when importing Headers,
Request and Response outside this module. Request and Response outside this module.
""" """
from warnings import catch_warnings, filterwarnings
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.http.headers import Headers from scrapy.http.headers import Headers
from scrapy.http.request import Request from scrapy.http.request import Request
from scrapy.http.request.form import FormRequest
from scrapy.http.request.json_request import JsonRequest from scrapy.http.request.json_request import JsonRequest
from scrapy.http.request.rpc import XmlRpcRequest from scrapy.http.request.rpc import XmlRpcRequest
from scrapy.http.response import Response from scrapy.http.response import Response
@ -17,19 +15,6 @@ from scrapy.http.response.html import HtmlResponse
from scrapy.http.response.json import JsonResponse from scrapy.http.response.json import JsonResponse
from scrapy.http.response.text import TextResponse from scrapy.http.response.text import TextResponse
from scrapy.http.response.xml import XmlResponse from scrapy.http.response.xml import XmlResponse
from scrapy.utils.deprecate import create_deprecated_class
with catch_warnings():
filterwarnings("ignore", category=ScrapyDeprecationWarning)
from scrapy.http.request.form import FormRequest as _FormRequest
FormRequest = create_deprecated_class(
name="FormRequest",
new_class=_FormRequest,
subclass_warn_message="{cls} inherits from deprecated class {old}, use the form2request library instead.",
instance_warn_message="{cls} is deprecated, use the form2request library instead.",
)
__all__ = [ __all__ = [
"FormRequest", "FormRequest",

View File

@ -136,9 +136,9 @@ class _DummyLock:
class WrappedRequest: class WrappedRequest:
"""Wraps a scrapy Request class with methods defined by urllib2.Request class to interact with CookieJar class """Wraps a :class:`scrapy.Request` class with methods defined by
the :class:`urllib.request.Request` class to interact with
see http://docs.python.org/library/urllib2.html#urllib2.Request the :class:`http.cookiejar.CookieJar` class.
""" """
def __init__(self, request: Request): def __init__(self, request: Request):

View File

@ -51,7 +51,7 @@ class VerboseCookie(TypedDict):
secure: NotRequired[bool] secure: NotRequired[bool]
CookiesT: TypeAlias = dict[str, str] | list[VerboseCookie] CookiesT: TypeAlias = dict[str | bytes, str | bytes] | list[VerboseCookie]
RequestTypeVar = TypeVar("RequestTypeVar", bound="Request") RequestTypeVar = TypeVar("RequestTypeVar", bound="Request")

View File

@ -32,19 +32,61 @@ if TYPE_CHECKING:
from scrapy.http.response.text import TextResponse from scrapy.http.response.text import TextResponse
warn(
"The entire scrapy.http.request.form module is deprecated. Use the "
"form2request library instead.",
ScrapyDeprecationWarning,
stacklevel=2,
)
FormdataVType: TypeAlias = str | Iterable[str] FormdataVType: TypeAlias = str | Iterable[str]
FormdataKVType: TypeAlias = tuple[str, FormdataVType] FormdataKVType: TypeAlias = tuple[str, FormdataVType]
FormdataType: TypeAlias = dict[str, FormdataVType] | list[FormdataKVType] | None FormdataType: TypeAlias = dict[str, FormdataVType] | list[FormdataKVType] | None
class FormRequest(Request): class FormRequest(Request):
"""A :class:`~scrapy.Request` subclass with a ``formdata`` parameter that
url-encodes the given data and assigns it to the request, which makes it
convenient to send arbitrary form data via HTTP POST or GET without an HTML
``<form>`` element to parse.
.. note:: To build a request from an HTML ``<form>`` element found in a
response, use :doc:`form2request <form2request:index>` instead. See
:ref:`form`.
The remaining arguments are the same as for the :class:`~scrapy.Request`
class and are not documented here.
:param formdata: a dictionary (or iterable of (key, value) tuples)
containing HTML form data which will be url-encoded. If
:attr:`~scrapy.Request.method` is not given and ``formdata`` is
provided, the method is set to ``"POST"`` and the data is assigned to
the request body; if the method is ``"GET"``, the data is added to the
URL query string instead.
:type formdata: dict or collections.abc.Iterable
To send data via HTTP POST, simulating an HTML form submission, return a
:class:`~scrapy.FormRequest` object from your spider:
.. skip: next
.. code-block:: python
return [
FormRequest(
url="http://www.example.com/post/action",
formdata={"name": "John Doe", "age": "27"},
callback=self.after_post,
)
]
To send the data in the URL query string instead, use the ``GET`` method:
.. skip: next
.. code-block:: python
return [
FormRequest(
url="http://www.example.com/search",
method="GET",
formdata={"q": "keyword", "page": "1"},
callback=self.parse_results,
)
]
"""
__slots__ = () __slots__ = ()
valid_form_methods: ClassVar[list[str]] = ["GET", "POST"] valid_form_methods: ClassVar[list[str]] = ["GET", "POST"]
@ -84,6 +126,13 @@ class FormRequest(Request):
formcss: str | None = None, formcss: str | None = None,
**kwargs: Any, **kwargs: Any,
) -> Self: ) -> Self:
warn(
"FormRequest.from_response() is deprecated. Use the form2request "
"library instead.",
ScrapyDeprecationWarning,
stacklevel=2,
)
kwargs.setdefault("encoding", response.encoding) kwargs.setdefault("encoding", response.encoding)
if formcss is not None: if formcss is not None:

View File

@ -1,6 +1,7 @@
""" """
This module implements the HtmlResponse class which adds encoding This module implements the :class:`HtmlResponse` class which is used as a
discovering through HTML encoding declarations to the TextResponse class. content type marker by :class:`~scrapy.selector.Selector` and can be used in
``isinstance()`` checks.
See documentation in docs/topics/request-response.rst See documentation in docs/topics/request-response.rst
""" """

View File

@ -1,6 +1,7 @@
""" """
This module implements the XmlResponse class which adds encoding This module implements the :class:`XmlResponse` class which is used as a
discovering through XML encoding declarations to the TextResponse class. content type marker by :class:`~scrapy.selector.Selector` and can be used in
``isinstance()`` checks.
See documentation in docs/topics/request-response.rst See documentation in docs/topics/request-response.rst
""" """

View File

@ -1,7 +1,7 @@
""" """
Scrapy Item Scrapy Item
See documentation in docs/topics/item.rst See documentation in docs/topics/items.rst
""" """
from __future__ import annotations from __future__ import annotations
@ -25,6 +25,25 @@ class Field(dict[str, Any]):
"""Container of field metadata""" """Container of field metadata"""
def _ordered_field_names(cls: type) -> list[str]:
"""Return the names of the :class:`Field` attributes of *cls* in definition
order.
Fields declared in base classes come first, ordered from the topmost base
to the most derived class. Within each class, fields keep their definition
order. A field redefined in a subclass keeps the position of its first
definition.
"""
names: list[str] = []
seen: set[str] = set()
for base in reversed(cls.__mro__):
for name, value in vars(base).items():
if isinstance(value, Field) and name not in seen:
seen.add(name)
names.append(name)
return names
class ItemMeta(ABCMeta): class ItemMeta(ABCMeta):
"""Metaclass_ of :class:`Item` that handles field definitions. """Metaclass_ of :class:`Item` that handles field definitions.
@ -39,13 +58,9 @@ class ItemMeta(ABCMeta):
_class = super().__new__(mcs, "x_" + class_name, new_bases, attrs) _class = super().__new__(mcs, "x_" + class_name, new_bases, attrs)
fields = getattr(_class, "fields", {}) fields = getattr(_class, "fields", {})
new_attrs = {} for n in _ordered_field_names(_class):
for n in dir(_class): fields[n] = getattr(_class, n)
v = getattr(_class, n) new_attrs = {n: v for n, v in attrs.items() if not isinstance(v, Field)}
if isinstance(v, Field):
fields[n] = v
elif n in attrs:
new_attrs[n] = attrs[n]
new_attrs["fields"] = fields new_attrs["fields"] = fields
new_attrs["_class"] = _class new_attrs["_class"] = _class
@ -80,6 +95,14 @@ class Item(MutableMapping[str, Any], object_ref, metaclass=ItemMeta):
#: those populated. The keys are the field names and the values are the #: those populated. The keys are the field names and the values are the
#: :class:`Field` objects used in the :ref:`Item declaration #: :class:`Field` objects used in the :ref:`Item declaration
#: <topics-items-declaring>`. #: <topics-items-declaring>`.
#:
#: Fields are kept in definition order: fields declared in base classes
#: come first, followed by fields declared in subclasses, and a field
#: redefined in a subclass keeps the position of its first definition.
#:
#: .. versionchanged:: VERSION
#: Fields are now returned in definition order rather than alphabetical
#: order.
fields: dict[str, Field] fields: dict[str, Field]
def __init__(self, *args: Any, **kwargs: Any): def __init__(self, *args: Any, **kwargs: Any):

View File

@ -9,7 +9,9 @@ its documentation in: docs/topics/link-extractors.rst
class Link: class Link:
"""Link objects represent an extracted link by the LinkExtractor. """Link objects represent an extracted link by the LinkExtractor.
Using the anchor tag sample below to illustrate the parameters:: Using the anchor tag sample below to illustrate the parameters:
.. code-block:: html
<a href="https://example.com/nofollow.html#foo" rel="nofollow">Dont follow this one</a> <a href="https://example.com/nofollow.html#foo" rel="nofollow">Dont follow this one</a>

View File

@ -58,18 +58,20 @@ class LogFormatter:
logging an action the method must return ``None``. logging an action the method must return ``None``.
Here is an example on how to create a custom log formatter to lower the severity level of Here is an example on how to create a custom log formatter to lower the severity level of
the log message when an item is dropped from the pipeline:: the log message when an item is dropped from the pipeline:
class PoliteLogFormatter(logformatter.LogFormatter): .. code-block:: python
def dropped(self, item, exception, response, spider):
return { class PoliteLogFormatter(logformatter.LogFormatter):
'level': logging.INFO, # lowering the level from logging.WARNING def dropped(self, item, exception, response, spider):
'msg': "Dropped: %(exception)s" + os.linesep + "%(item)s", return {
'args': { "level": logging.INFO, # lowering the level from logging.WARNING
'exception': exception, "msg": "Dropped: %(exception)s" + os.linesep + "%(item)s",
'item': item, "args": {
} "exception": exception,
} "item": item,
},
}
""" """
def crawled( def crawled(

View File

@ -1,7 +1,5 @@
""" """
Mail sending helpers Mail sending helpers
See documentation in docs/topics/email.rst
""" """
from __future__ import annotations from __future__ import annotations

View File

@ -1,7 +1,7 @@
""" """
Item pipeline Item pipeline
See documentation in docs/item-pipeline.rst See documentation in docs/topics/item-pipeline.rst
""" """
from __future__ import annotations from __future__ import annotations

View File

@ -423,7 +423,7 @@ class FTPFilesStore:
class FilesPipeline(MediaPipeline): class FilesPipeline(MediaPipeline):
"""Abstract pipeline that implement the file downloading """Pipeline that implements file downloading.
This pipeline tries to minimize network transfers and file processing, This pipeline tries to minimize network transfers and file processing,
doing stat of the files and determining if file is new, up-to-date or doing stat of the files and determining if file is new, up-to-date or

View File

@ -11,14 +11,20 @@ import hashlib
import warnings import warnings
from contextlib import suppress from contextlib import suppress
from io import BytesIO from io import BytesIO
from typing import TYPE_CHECKING, Any, ClassVar from typing import TYPE_CHECKING, Any, ClassVar, cast
from itemadapter import ItemAdapter from itemadapter import ItemAdapter
from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning
from scrapy.http import Request, Response from scrapy.http import Request, Response
from scrapy.http.request import NO_CALLBACK from scrapy.http.request import NO_CALLBACK
from scrapy.pipelines.files import FileException, FilesPipeline, _md5sum from scrapy.pipelines.files import (
FileException,
FilesPipeline,
GCSFilesStore,
S3FilesStore,
_md5sum,
)
from scrapy.utils.defer import ensure_awaitable from scrapy.utils.defer import ensure_awaitable
from scrapy.utils.python import to_bytes from scrapy.utils.python import to_bytes
@ -33,6 +39,7 @@ if TYPE_CHECKING:
from scrapy.crawler import Crawler from scrapy.crawler import Crawler
from scrapy.pipelines.media import FileInfoOrError, MediaPipeline from scrapy.pipelines.media import FileInfoOrError, MediaPipeline
from scrapy.settings import BaseSettings
class ImageException(FileException): class ImageException(FileException):
@ -40,7 +47,7 @@ class ImageException(FileException):
class ImagesPipeline(FilesPipeline): class ImagesPipeline(FilesPipeline):
"""Abstract pipeline that implement the image thumbnail generation logic""" """Pipeline that implements the handling logic specific to images."""
MEDIA_NAME: str = "image" MEDIA_NAME: str = "image"
@ -126,6 +133,20 @@ class ImagesPipeline(FilesPipeline):
) -> str: ) -> str:
return await self.image_downloaded(response, request, info, item=item) return await self.image_downloaded(response, request, info, item=item)
@classmethod
def _update_stores(cls, settings: BaseSettings) -> None:
super()._update_stores(settings)
s3store: type[S3FilesStore] = cast(
"type[S3FilesStore]", cls.STORE_SCHEMES["s3"]
)
s3store.POLICY = settings["IMAGES_STORE_S3_ACL"]
gcs_store: type[GCSFilesStore] = cast(
"type[GCSFilesStore]", cls.STORE_SCHEMES["gs"]
)
gcs_store.POLICY = settings["IMAGES_STORE_GCS_ACL"] or None
async def image_downloaded( async def image_downloaded(
self, self,
response: Response, response: Response,

View File

@ -92,9 +92,10 @@ class ScrapyPriorityQueue:
- The :data:`~scrapy.Request.priority` of the request. - The :data:`~scrapy.Request.priority` of the request.
For each combination of the above seen, this class creates an instance of For each combination of the above seen, this class creates an instance of
*downstream_queue_cls* with *key* set to a subdirectory of the persistence *downstream_queue_cls* (or *start_queue_cls* for start requests if it was
directory, named as the request priority (e.g. ``1``), with an ``s`` suffix passed) with *key* set to a subdirectory of the persistence directory,
in case of a start request (e.g. ``1s``). named as the negated request priority (e.g. ``-1``), with an ``s`` suffix
in case of a start request (e.g. ``-1s``).
""" """
@classmethod @classmethod
@ -141,10 +142,14 @@ class ScrapyPriorityQueue:
q = self.qfactory(priority) q = self.qfactory(priority)
if q: if q:
self.queues[priority] = q self.queues[priority] = q
else:
q.close()
if self._start_queue_cls: if self._start_queue_cls:
q = self._sqfactory(priority) q = self._sqfactory(priority)
if q: if q:
self._start_queues[priority] = q self._start_queues[priority] = q
else:
q.close()
self.curprio = min(startprios) self.curprio = min(startprios)

View File

@ -75,7 +75,7 @@ class HostResolution:
def __init__(self, name: str): def __init__(self, name: str):
self.name: str = name self.name: str = name
def cancel(self) -> None: def cancel(self) -> None: # pragma: no cover
raise NotImplementedError raise NotImplementedError

View File

@ -40,18 +40,13 @@ class ResponseTypes:
self.classes: dict[str, type[Response]] = {} self.classes: dict[str, type[Response]] = {}
self.mimetypes: MimeTypes = MimeTypes() self.mimetypes: MimeTypes = MimeTypes()
mimedata = get_data("scrapy", "mime.types") mimedata = get_data("scrapy", "mime.types")
if not mimedata: assert mimedata is not None
raise ValueError(
"The mime.types file is not found in the Scrapy installation"
)
self.mimetypes.readfp(StringIO(mimedata.decode("utf8"))) self.mimetypes.readfp(StringIO(mimedata.decode("utf8")))
for mimetype, cls in self.CLASSES.items(): for mimetype, cls in self.CLASSES.items():
self.classes[mimetype] = load_object(cls) self.classes[mimetype] = load_object(cls)
def from_mimetype(self, mimetype: str) -> type[Response]: def from_mimetype(self, mimetype: str) -> type[Response]:
"""Return the most appropriate Response class for the given mimetype""" """Return the most appropriate Response class for the given mimetype"""
if mimetype is None:
return Response
if mimetype in self.classes: if mimetype in self.classes:
return self.classes[mimetype] return self.classes[mimetype]
basetype = f"{mimetype.split('/', maxsplit=1)[0]}/*" basetype = f"{mimetype.split('/', maxsplit=1)[0]}/*"

View File

@ -1,10 +1,6 @@
"""
XPath selectors based on lxml
"""
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any, Literal
from parsel import Selector as _ParselSelector from parsel import Selector as _ParselSelector
@ -18,13 +14,10 @@ __all__ = ["Selector", "SelectorList"]
_NOT_SET = object() _NOT_SET = object()
def _st(response: TextResponse | None, st: str | None) -> str: SelectorType = Literal["html", "xml", "json", "text"]
if st is None:
return "xml" if isinstance(response, XmlResponse) else "html"
return st
def _response_from_text(text: str | bytes, st: str | None) -> TextResponse: def _response_from_text(text: str | bytes, st: SelectorType | None) -> TextResponse:
rt: type[TextResponse] = XmlResponse if st == "xml" else HtmlResponse rt: type[TextResponse] = XmlResponse if st == "xml" else HtmlResponse
return rt(url="about:blank", encoding="utf-8", body=to_bytes(text, "utf-8")) return rt(url="about:blank", encoding="utf-8", body=to_bytes(text, "utf-8"))
@ -49,23 +42,16 @@ class Selector(_ParselSelector, object_ref):
``response`` isn't available. Using ``text`` and ``response`` together is ``response`` isn't available. Using ``text`` and ``response`` together is
undefined behavior. undefined behavior.
``type`` defines the selector type, it can be ``"html"``, ``"xml"``, ``"json"`` ``type`` defines the selector type, it can be ``"html"``, ``"xml"``,
or ``None`` (default). ``"json"``, ``"text"`` or ``None`` (default). It's passed to
:class:`parsel.Selector` and its meaning is defined there. However, when
``type`` is ``None``, it is set to ``"xml"`` for an
:class:`~scrapy.http.XmlResponse` and to ``"html"`` otherwise before
passing it to :class:`parsel.Selector`.
If ``type`` is ``None``, the selector automatically chooses the best type .. note:: JSON selector support requires ``parsel`` 1.8.0 or higher. With
based on ``response`` type (see below), or defaults to ``"html"`` in case it older versions setting ``type`` to ``"json"`` or ``"text"`` is not
is used together with ``text``. supported.
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
* ``"json"`` for :class:`~scrapy.http.TextResponse` type
* ``"html"`` for anything else
Otherwise, if ``type`` is set, the selector type will be forced and no
detection will occur.
""" """
__slots__ = ["response"] __slots__ = ["response"]
@ -75,7 +61,7 @@ class Selector(_ParselSelector, object_ref):
self, self,
response: TextResponse | None = None, response: TextResponse | None = None,
text: str | None = None, text: str | None = None,
type: str | None = None, # noqa: A002 type: SelectorType | None = None, # noqa: A002
root: Any | None = _NOT_SET, root: Any | None = _NOT_SET,
**kwargs: Any, **kwargs: Any,
): ):
@ -84,10 +70,11 @@ class Selector(_ParselSelector, object_ref):
f"{self.__class__.__name__}.__init__() received both response and text" f"{self.__class__.__name__}.__init__() received both response and text"
) )
st = _st(response, type) if type is None:
type = "xml" if isinstance(response, XmlResponse) else "html" # noqa: A001
if text is not None: if text is not None:
response = _response_from_text(text, st) response = _response_from_text(text, type)
if response is not None: if response is not None:
text = response.text text = response.text
@ -98,4 +85,4 @@ class Selector(_ParselSelector, object_ref):
if root is not _NOT_SET: if root is not _NOT_SET:
kwargs["root"] = root kwargs["root"] = root
super().__init__(text=text, type=st, **kwargs) super().__init__(text=text, type=type, **kwargs)

View File

@ -454,9 +454,10 @@ class BaseSettings(MutableMapping[str, Any]):
""" """
Store a key/value attribute with a given priority. Store a key/value attribute with a given priority.
Settings should be populated *before* configuring the Crawler object Settings should be populated *before* the Crawler object applies them
(through the :meth:`~scrapy.crawler.Crawler.configure` method), (in the :meth:`~scrapy.crawler.Crawler.crawl_async` or
otherwise they won't have any effect. :meth:`~scrapy.crawler.Crawler.crawl` method), otherwise they won't
have any effect.
:param name: the setting name :param name: the setting name
:type name: str :type name: str
@ -613,7 +614,7 @@ class BaseSettings(MutableMapping[str, Any]):
""" """
Make a deep copy of current settings. Make a deep copy of current settings.
This method returns a new instance of the :class:`Settings` class, This method returns a new instance of this class,
populated with the same values and their priorities. populated with the same values and their priorities.
Modifications to the new object won't be reflected on the original Modifications to the new object won't be reflected on the original
@ -658,7 +659,7 @@ class BaseSettings(MutableMapping[str, Any]):
Make a copy of current settings and convert to a dict. Make a copy of current settings and convert to a dict.
This method returns a new dict populated with the same values This method returns a new dict populated with the same values
and their priorities as the current settings. as the current settings.
Modifications to the returned dict won't be reflected on the original Modifications to the returned dict won't be reflected on the original
settings. settings.

View File

@ -75,7 +75,7 @@ if TYPE_CHECKING:
# running event loop. # running event loop.
# #
# Side note: it should be possible to remove _request_deferred() by using # Side note: it should be possible to remove _request_deferred() by using
# engine.download_async() instead of engine.schedule(), losing the usual stuff # engine.download_async() instead of engine.crawl(), losing the usual stuff
# like spider middlewares (none of which should be important). # like spider middlewares (none of which should be important).
# #
# Other architecture problems: # Other architecture problems:
@ -188,7 +188,8 @@ class Shell:
async def _schedule(self, request: Request, spider: Spider | None) -> Response: async def _schedule(self, request: Request, spider: Spider | None) -> Response:
"""Send the request to the engine, wait for the result. """Send the request to the engine, wait for the result.
Runs in the reactor thread. Runs in the reactor thread when using the reactor, or in the asyncio
event loop thread otherwise.
""" """
if not self.spider: if not self.spider:
await self._open_spider(spider) await self._open_spider(spider)

View File

@ -75,7 +75,7 @@ class BaseSpiderMiddleware:
) -> Request | None: ) -> Request | None:
"""Return a processed request from the spider output. """Return a processed request from the spider output.
This method is called with a single request from the start seeds or the This method is called with a single request from ``start()`` or the
spider output. It should return the same or a different request, or spider output. It should return the same or a different request, or
``None`` to ignore it. ``None`` to ignore it.
@ -84,7 +84,7 @@ class BaseSpiderMiddleware:
:param response: the response being processed :param response: the response being processed
:type response: :class:`~scrapy.http.Response` object or ``None`` for :type response: :class:`~scrapy.http.Response` object or ``None`` for
start seeds start requests
:return: the processed request or ``None`` :return: the processed request or ``None``
""" """
@ -93,7 +93,7 @@ class BaseSpiderMiddleware:
def get_processed_item(self, item: Any, response: Response | None) -> Any: def get_processed_item(self, item: Any, response: Response | None) -> Any:
"""Return a processed item from the spider output. """Return a processed item from the spider output.
This method is called with a single item from the start seeds or the This method is called with a single item from ``start()`` or the
spider output. It should return the same or a different item, or spider output. It should return the same or a different item, or
``None`` to ignore it. ``None`` to ignore it.
@ -102,7 +102,7 @@ class BaseSpiderMiddleware:
:param response: the response being processed :param response: the response being processed
:type response: :class:`~scrapy.http.Response` object or ``None`` for :type response: :class:`~scrapy.http.Response` object or ``None`` for
start seeds start items
:return: the processed item or ``None`` :return: the processed item or ``None``
""" """

View File

@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
class HttpError(IgnoreRequest): class HttpError(IgnoreRequest):
"""A non-200 response was filtered""" """A non-2xx response was filtered"""
def __init__(self, response: Response, *args: Any, **kwargs: Any): def __init__(self, response: Response, *args: Any, **kwargs: Any):
self.response = response self.response = response

View File

@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, cast
from urllib.parse import urlparse from urllib.parse import urlparse
from warnings import warn from warnings import warn
from scrapy.exceptions import NotConfigured from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning
from scrapy.http import Request, Response from scrapy.http import Request, Response
from scrapy.spidermiddlewares.base import BaseSpiderMiddleware from scrapy.spidermiddlewares.base import BaseSpiderMiddleware
from scrapy.utils.misc import load_object from scrapy.utils.misc import load_object
@ -92,7 +92,7 @@ class ReferrerPolicy(ABC):
) )
def origin(self, url: str) -> str | None: def origin(self, url: str) -> str | None:
"""Return serialized origin (scheme, host, path) for a request or response URL.""" """Return serialized origin (scheme, host, port) for a request or response URL."""
return self.strip_url(url, origin_only=True) return self.strip_url(url, origin_only=True)
def potentially_trustworthy(self, url: str) -> bool: def potentially_trustworthy(self, url: str) -> bool:
@ -308,6 +308,12 @@ class RefererMiddleware(BaseSpiderMiddleware):
# Reference: https://www.w3.org/TR/referrer-policy/#referrer-policy-empty-string # Reference: https://www.w3.org/TR/referrer-policy/#referrer-policy-empty-string
self.policies[""] = NoReferrerWhenDowngradePolicy self.policies[""] = NoReferrerWhenDowngradePolicy
if settings is None: if settings is None:
warn(
"Instantiating RefererMiddleware without a 'settings' argument is "
"deprecated.",
ScrapyDeprecationWarning,
stacklevel=2,
)
return return
setting_policies = settings.getdict("REFERRER_POLICIES") setting_policies = settings.getdict("REFERRER_POLICIES")
for policy_name, policy_class_import_path in setting_policies.items(): for policy_name, policy_class_import_path in setting_policies.items():
@ -349,7 +355,7 @@ class RefererMiddleware(BaseSpiderMiddleware):
response = kwargs.pop("resp_or_url") response = kwargs.pop("resp_or_url")
warn( warn(
"Passing 'resp_or_url' is deprecated, use 'response' instead.", "Passing 'resp_or_url' is deprecated, use 'response' instead.",
DeprecationWarning, ScrapyDeprecationWarning,
stacklevel=2, stacklevel=2,
) )
if response is None: if response is None:
@ -360,7 +366,7 @@ class RefererMiddleware(BaseSpiderMiddleware):
warn( warn(
"Passing a response URL to RefererMiddleware.policy() instead " "Passing a response URL to RefererMiddleware.policy() instead "
"of a Response object is deprecated.", "of a Response object is deprecated.",
DeprecationWarning, ScrapyDeprecationWarning,
stacklevel=2, stacklevel=2,
) )
allow_import_path = True allow_import_path = True

View File

@ -125,12 +125,6 @@ class Spider(object_ref):
.. seealso:: :ref:`start-requests` .. seealso:: :ref:`start-requests`
""" """
if not self.start_urls and hasattr(self, "start_url"):
raise AttributeError(
"Crawling could not start: 'start_urls' not found "
"or empty (but found 'start_url' attribute instead, "
"did you miss an 's'?)"
)
for url in self.start_urls: for url in self.start_urls:
yield Request(url, dont_filter=True) yield Request(url, dont_filter=True)

View File

@ -110,6 +110,7 @@ class CrawlSpider(Spider):
"deprecated: it will be removed in future Scrapy releases. " "deprecated: it will be removed in future Scrapy releases. "
"Please override the CrawlSpider.parse_with_rules method " "Please override the CrawlSpider.parse_with_rules method "
"instead.", "instead.",
ScrapyDeprecationWarning,
stacklevel=2, stacklevel=2,
) )
@ -199,6 +200,7 @@ class CrawlSpider(Spider):
"The CrawlSpider._parse_response method is deprecated: " "The CrawlSpider._parse_response method is deprecated: "
"it will be removed in future Scrapy releases. " "it will be removed in future Scrapy releases. "
"Please use the CrawlSpider.parse_with_rules method instead.", "Please use the CrawlSpider.parse_with_rules method instead.",
ScrapyDeprecationWarning,
stacklevel=2, stacklevel=2,
) )
return self.parse_with_rules(response, callback, cb_kwargs, follow) return self.parse_with_rules(response, callback, cb_kwargs, follow)

View File

@ -54,17 +54,21 @@ class XMLFeedSpider(Spider):
return response return response
def parse_node(self, response: Response, selector: Selector) -> Any: def parse_node(self, response: Response, selector: Selector) -> Any:
"""This method must be overridden with your custom spider functionality""" """This method is called for the nodes matching the provided tag name
(itertag). Receives the response and an Selector for each node.
This method must return either an item, a request, or a list
containing any of them.
This method must be overridden with your custom spider functionality.
"""
if hasattr(self, "parse_item"): # backward compatibility if hasattr(self, "parse_item"): # backward compatibility
return self.parse_item(response, selector) return self.parse_item(response, selector)
raise NotImplementedError raise NotImplementedError
def parse_nodes(self, response: Response, nodes: Iterable[Selector]) -> Any: def parse_nodes(self, response: Response, nodes: Iterable[Selector]) -> Any:
"""This method is called for the nodes matching the provided tag name """This method is called for the nodes matching the provided tag name
(itertag). Receives the response and an Selector for each node. (itertag). Receives the response and an iterable of Selectors.
Overriding this method is mandatory. Otherwise, you spider won't work.
This method must return either an item, a request, or a list
containing any of them.
""" """
for selector in nodes: for selector in nodes:
@ -113,6 +117,9 @@ class CSVFeedSpider(Spider):
It receives a CSV file in a response; iterates through each of its rows, It receives a CSV file in a response; iterates through each of its rows,
and calls parse_row with a dict containing each field's data. and calls parse_row with a dict containing each field's data.
This spider also gives the opportunity to override adapt_response and
process_results methods for pre and post-processing purposes.
You can set some options regarding the CSV file, such as the delimiter, quotechar You can set some options regarding the CSV file, such as the delimiter, quotechar
and the file's headers. and the file's headers.
""" """
@ -136,16 +143,14 @@ class CSVFeedSpider(Spider):
return response return response
def parse_row(self, response: Response, row: dict[str, str]) -> Any: def parse_row(self, response: Response, row: dict[str, str]) -> Any:
"""This method must be overridden with your custom spider functionality""" """Receives a response and a dict (representing each row) with a key for
each provided (or detected) header of the CSV file.
This method must be overridden with your custom spider functionality.
"""
raise NotImplementedError raise NotImplementedError
def parse_rows(self, response: Response) -> Any: def parse_rows(self, response: Response) -> Any:
"""Receives a response and a dict (representing each row) with a key for
each provided (or detected) header of the CSV file. This spider also
gives the opportunity to override adapt_response and
process_results methods for pre and post-processing purposes.
"""
for row in csviter( for row in csviter(
response, self.delimiter, self.headers, quotechar=self.quotechar response, self.delimiter, self.headers, quotechar=self.quotechar
): ):

View File

@ -9,5 +9,5 @@ from itemadapter import ItemAdapter
class ${ProjectName}Pipeline: class ${ProjectName}Pipeline:
def process_item(self, item, spider): def process_item(self, item):
return item return item

View File

@ -1,7 +1,15 @@
import sys
from OpenSSL import __version__ as PYOPENSSL_VERSION_STRING from OpenSSL import __version__ as PYOPENSSL_VERSION_STRING
from packaging.version import Version from packaging.version import Version
from twisted import version as TWISTED_VERSION from twisted import version as TWISTED_VERSION
from twisted.python.versions import Version as TxVersion from twisted.python.versions import Version as TxVersion
from w3lib import __version__ as W3LIB_VERSION_STRING
# improved urllib.robotparser, https://github.com/python/cpython/pull/149374
STDLIB_IMPROVED_ROBOTFILEPARSER = sys.version_info >= (3, 14, 5) or (
(3, 13, 14) <= sys.version_info < (3, 14)
)
TWISTED_FAILURE_HAS_STACK = TWISTED_VERSION < TxVersion("twisted", 24, 10, 0) TWISTED_FAILURE_HAS_STACK = TWISTED_VERSION < TxVersion("twisted", 24, 10, 0)
# changes to private _sslverify code, https://github.com/twisted/twisted/pull/12506 # changes to private _sslverify code, https://github.com/twisted/twisted/pull/12506
@ -10,7 +18,11 @@ TWISTED_TLS_NEW_IMPL = TWISTED_VERSION >= TxVersion("twisted", 26, 4, 0)
TWISTED_TLS_LIMITS_OFFBY1 = TWISTED_VERSION < TxVersion("twisted", 26, 4, 0) TWISTED_TLS_LIMITS_OFFBY1 = TWISTED_VERSION < TxVersion("twisted", 26, 4, 0)
PYOPENSSL_VERSION = Version(PYOPENSSL_VERSION_STRING) PYOPENSSL_VERSION = Version(PYOPENSSL_VERSION_STRING)
# SSL.Context.use_certificate() wants an X509 object, SSL.Context.use_privatekey() wants a PKey object # pyOpenSSL X.509 APIs are deprecated and cryptography-based ones are preferred
PYOPENSSL_WANTS_X509_PKEY = PYOPENSSL_VERSION < Version("24.3.0") PYOPENSSL_X509_DEPRECATED = PYOPENSSL_VERSION >= Version("24.3.0")
# SSL.Context.set_cipher_list() creates a temporary connection, making the context immutable # SSL.Context.set_cipher_list() creates a temporary connection, making the context immutable
PYOPENSSL_SET_CIPHER_LIST_TMP_CONN = PYOPENSSL_VERSION < Version("25.2.0") PYOPENSSL_SET_CIPHER_LIST_TMP_CONN = PYOPENSSL_VERSION < Version("25.2.0")
W3LIB_VERSION = Version(W3LIB_VERSION_STRING)
# safe_url_string() strips the input, https://github.com/scrapy/w3lib/pull/207
W3LIB_STRIPS_URLS = W3LIB_VERSION >= Version("2.1.1")

View File

@ -1,8 +1,6 @@
""" """
This module contains data types used by Scrapy which are not included in the This module contains data types used by Scrapy which are not included in the
Python Standard Library. Python Standard Library.
This module must not depend on any module outside the Standard Library.
""" """
from __future__ import annotations from __future__ import annotations
@ -152,7 +150,9 @@ class LocalCache(OrderedDict[_KT, _VT]):
self.limit: int | None = limit self.limit: int | None = limit
def __setitem__(self, key: _KT, value: _VT) -> None: def __setitem__(self, key: _KT, value: _VT) -> None:
if self.limit: if self.limit is not None:
if self.limit == 0:
return
while len(self) >= self.limit: while len(self) >= self.limit:
self.popitem(last=False) self.popitem(last=False)
super().__setitem__(key, value) super().__setitem__(key, value)

View File

@ -103,7 +103,7 @@ async def _defer_sleep_async() -> None:
def defer_result(result: Any) -> Deferred[Any]: # pragma: no cover def defer_result(result: Any) -> Deferred[Any]: # pragma: no cover
warnings.warn( warnings.warn(
"scrapy.utils.defer.defer_result() is deprecated, use" "scrapy.utils.defer.defer_result() is deprecated, use"
" twisted.internet.defer.success() and twisted.internet.defer.fail()," " twisted.internet.defer.succeed() and twisted.internet.defer.fail(),"
" plus an explicit sleep if needed, or explicit reactor.callLater().", " plus an explicit sleep if needed, or explicit reactor.callLater().",
category=ScrapyDeprecationWarning, category=ScrapyDeprecationWarning,
stacklevel=2, stacklevel=2,
@ -469,22 +469,22 @@ def _maybeDeferred_coro(
def deferred_to_future(d: Deferred[_T]) -> Future[_T]: def deferred_to_future(d: Deferred[_T]) -> Future[_T]:
"""Return an :class:`asyncio.Future` object that wraps *d*. """Return an :class:`asyncio.Future` object that wraps *d*.
This function requires This function requires an installed asyncio reactor or a running asyncio
:class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor` to be event loop, see :ref:`using-asyncio`.
installed.
When :ref:`using the asyncio reactor <install-asyncio>`, you cannot await In this state you cannot await on :class:`~twisted.internet.defer.Deferred`
on :class:`~twisted.internet.defer.Deferred` objects from :ref:`Scrapy objects from :ref:`Scrapy callables defined as coroutines
callables defined as coroutines <coroutine-support>`, you can only await on <coroutine-support>`, you can only await on ``Future`` objects. Wrapping
``Future`` objects. Wrapping ``Deferred`` objects into ``Future`` objects ``Deferred`` objects into ``Future`` objects allows you to wait on them:
allows you to wait on them::
.. code-block:: python
class MySpider(Spider): class MySpider(Spider):
... ...
async def parse(self, response): async def parse(self, response):
additional_request = scrapy.Request('https://example.org/price') deferred = some_dfd_helper()
deferred = self.crawler.engine.download(additional_request) result = await deferred_to_future(deferred)
additional_response = await deferred_to_future(deferred)
.. versionchanged:: 2.14 .. versionchanged:: 2.14
This function no longer installs an asyncio loop if called before the This function no longer installs an asyncio loop if called before the
@ -492,7 +492,10 @@ def deferred_to_future(d: Deferred[_T]) -> Future[_T]:
in this case. in this case.
""" """
if not is_asyncio_available(): if not is_asyncio_available():
raise RuntimeError("deferred_to_future() requires AsyncioSelectorReactor.") raise RuntimeError(
"deferred_to_future() requires an installed asyncio reactor"
" or a running asyncio event loop."
)
return d.asFuture(asyncio.get_event_loop()) return d.asFuture(asyncio.get_event_loop())
@ -501,23 +504,26 @@ def maybe_deferred_to_future(d: Deferred[_T]) -> Deferred[_T] | Future[_T]:
defined as a coroutine <coroutine-support>`. defined as a coroutine <coroutine-support>`.
What you can await in Scrapy callables defined as coroutines depends on the What you can await in Scrapy callables defined as coroutines depends on the
value of :setting:`TWISTED_REACTOR`: value of :setting:`TWISTED_REACTOR` and :setting:`TWISTED_REACTOR_ENABLED`:
- When :ref:`using the asyncio reactor <install-asyncio>`, you can only - When :ref:`using the asyncio reactor <install-asyncio>`, or :ref:`not
await on :class:`asyncio.Future` objects. using a reactor at all <asyncio-without-reactor>`, you can only await
on :class:`asyncio.Future` objects.
- When not using the asyncio reactor, you can only await on - When :ref:`using a non-asyncio reactor <disable-asyncio>`, you can only
:class:`~twisted.internet.defer.Deferred` objects. await on :class:`~twisted.internet.defer.Deferred` objects.
If you want to write code that uses ``Deferred`` objects but works with any If you want to write code that uses ``Deferred`` objects but works in both
reactor, use this function on all ``Deferred`` objects:: of these states, use this function on all ``Deferred`` objects:
.. code-block:: python
class MySpider(Spider): class MySpider(Spider):
... ...
async def parse(self, response): async def parse(self, response):
additional_request = scrapy.Request('https://example.org/price') deferred = some_dfd_helper()
deferred = self.crawler.engine.download(additional_request) result = await maybe_deferred_to_future(deferred)
additional_response = await maybe_deferred_to_future(deferred)
""" """
if not is_asyncio_available(): if not is_asyncio_available():
return d return d

View File

@ -43,15 +43,18 @@ def create_deprecated_class(
It can be used to rename a base class in a library. For example, if we It can be used to rename a base class in a library. For example, if we
have have
class OldName(SomeClass): .. code-block:: python
# ...
and we want to rename it to NewName, we can do the following:: class OldName(SomeClass): ...
class NewName(SomeClass): and we want to rename it to NewName, we can do the following:
# ...
OldName = create_deprecated_class('OldName', NewName) .. code-block:: python
class NewName(SomeClass): ...
OldName = create_deprecated_class("OldName", NewName)
Then, if user class inherits from OldName, warning is issued. Also, if Then, if user class inherits from OldName, warning is issued. Also, if
some code uses ``issubclass(sub, OldName)`` or ``isinstance(sub(), OldName)`` some code uses ``issubclass(sub, OldName)`` or ``isinstance(sub(), OldName)``

View File

@ -1,4 +1,5 @@
import posixpath import posixpath
from contextlib import closing
from ftplib import FTP, error_perm from ftplib import FTP, error_perm
from posixpath import dirname from posixpath import dirname
from typing import IO from typing import IO
@ -32,7 +33,7 @@ def ftp_store_file(
"""Opens a FTP connection with passed credentials,sets current directory """Opens a FTP connection with passed credentials,sets current directory
to the directory extracted from given path, then uploads the file to server to the directory extracted from given path, then uploads the file to server
""" """
with FTP() as ftp: with FTP() as ftp, closing(file):
ftp.connect(host, port) ftp.connect(host, port)
ftp.login(username, password) ftp.login(username, password)
if use_active_mode: if use_active_mode:
@ -42,4 +43,3 @@ def ftp_store_file(
ftp_makedirs_cwd(ftp, dirname) ftp_makedirs_cwd(ftp, dirname)
command = "STOR" if overwrite else "APPE" command = "STOR" if overwrite else "APPE"
ftp.storbinary(f"{command} {filename}", file) ftp.storbinary(f"{command} {filename}", file)
file.close()

View File

@ -149,11 +149,12 @@ def install_scrapy_root_handler(settings: Settings) -> None:
def _uninstall_scrapy_root_handler() -> None: def _uninstall_scrapy_root_handler() -> None:
global _scrapy_root_handler # noqa: PLW0603 global _scrapy_root_handler # noqa: PLW0603
if ( if _scrapy_root_handler is None:
_scrapy_root_handler is not None return
and _scrapy_root_handler in logging.root.handlers
): if _scrapy_root_handler in logging.root.handlers:
logging.root.removeHandler(_scrapy_root_handler) logging.root.removeHandler(_scrapy_root_handler)
_scrapy_root_handler.close()
_scrapy_root_handler = None _scrapy_root_handler = None
@ -247,8 +248,7 @@ def logformatter_adapter(
) -> tuple[int, str, dict[str, Any] | tuple[Any, ...]]: ) -> tuple[int, str, dict[str, Any] | tuple[Any, ...]]:
""" """
Helper that takes the dictionary output from the methods in LogFormatter Helper that takes the dictionary output from the methods in LogFormatter
and adapts it into a tuple of positional arguments for logger.log calls, and adapts it into a tuple of positional arguments for logger.log calls.
handling backward compatibility as well.
""" """
level = logkws.get("level", logging.INFO) level = logkws.get("level", logging.INFO)

View File

@ -135,14 +135,9 @@ def walk_modules(path: str) -> list[ModuleType]: # pragma: no cover
return list(walk_modules_iter(path)) return list(walk_modules_iter(path))
def md5sum(file: IO[bytes]) -> str: def md5sum(file: IO[bytes]) -> str: # pragma: no cover
"""Calculate the md5 checksum of a file-like object without reading its """Calculate the md5 checksum of a file-like object without reading its
whole content in memory. whole content in memory."""
>>> from io import BytesIO
>>> md5sum(BytesIO(b'file content to hash'))
'784406af91dd5a54fbb9c84c2236595a'
"""
warnings.warn( warnings.warn(
( (
"The scrapy.utils.misc.md5sum function is deprecated and will be " "The scrapy.utils.misc.md5sum function is deprecated and will be "
@ -162,7 +157,7 @@ def md5sum(file: IO[bytes]) -> str:
def rel_has_nofollow(rel: str | None) -> bool: def rel_has_nofollow(rel: str | None) -> bool:
"""Return True if link rel attribute has nofollow type""" """Return True if link rel attribute has nofollow type"""
return rel is not None and "nofollow" in rel.replace(",", " ").split() return rel is not None and "nofollow" in rel.lower().replace(",", " ").split()
class SupportsFromCrawler(Protocol[_T_co, _P]): class SupportsFromCrawler(Protocol[_T_co, _P]):

View File

@ -7,6 +7,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import logging
from typing import TYPE_CHECKING, Any, Protocol from typing import TYPE_CHECKING, Any, Protocol
from urllib.parse import urlunparse from urllib.parse import urlunparse
from weakref import WeakKeyDictionary from weakref import WeakKeyDictionary
@ -25,6 +26,9 @@ if TYPE_CHECKING:
from typing_extensions import Self from typing_extensions import Self
from scrapy.crawler import Crawler from scrapy.crawler import Crawler
from scrapy.http.request import CookiesT, VerboseCookie
logger = logging.getLogger(__name__)
_fingerprint_cache: WeakKeyDictionary[ _fingerprint_cache: WeakKeyDictionary[
@ -179,11 +183,52 @@ def _get_method(obj: Any, name: Any) -> Any:
raise ValueError(f"Method {name!r} not found in: {obj}") from None raise ValueError(f"Method {name!r} not found in: {obj}") from None
def _to_verbose_cookies(cookies: CookiesT) -> list[VerboseCookie]:
"""Return a list of verbose cookies from ``request.cookies``.
The list of dicts form is returned as is, the dict one is converted first.
"""
if isinstance(cookies, dict):
return [{"name": k, "value": v} for k, v in cookies.items()]
return cookies
def _decode_cookie(cookie: VerboseCookie, request: Request) -> dict[str, str] | None:
"""Return a dict with non-flag verbose cookie values converted to strings.
``name``, ``value``, ``path``, ``domain`` are included, ``secure`` isn't.
"""
decoded = {}
for key in ("name", "value", "path", "domain"):
value = cookie.get(key)
if value is None:
if key in {"name", "value"}:
logger.warning(
f"Invalid cookie found in request {request}:"
f" {cookie} ('{key}' is missing)"
)
return None
continue
if isinstance(value, (bool, float, int, str)):
decoded[key] = str(value)
else:
assert isinstance(value, bytes)
try:
decoded[key] = value.decode("utf8")
except UnicodeDecodeError:
logger.warning(
f"Non UTF-8 encoded cookie found in request {request}: {cookie}",
)
decoded[key] = value.decode("latin1", errors="replace")
return decoded
def request_to_curl(request: Request) -> str: def request_to_curl(request: Request) -> str:
""" """
Converts a :class:`~scrapy.Request` object to a curl command. Converts a :class:`~scrapy.Request` object to a curl command.
:param :class:`~scrapy.Request`: Request object to be converted :param request: Request object to be converted
:return: string containing the curl command :return: string containing the curl command
""" """
method = request.method method = request.method
@ -195,17 +240,14 @@ def request_to_curl(request: Request) -> str:
) )
url = request.url url = request.url
cookies = ""
if request.cookies: cookie_list: list[VerboseCookie] = _to_verbose_cookies(request.cookies)
if isinstance(request.cookies, dict): pairs = [
cookie = "; ".join(f"{k}={v}" for k, v in request.cookies.items()) f"{decoded['name']}={decoded['value']}"
cookies = f"--cookie '{cookie}'" for c in cookie_list
elif isinstance(request.cookies, list): if (decoded := _decode_cookie(c, request)) is not None
cookie = "; ".join( ]
f"{next(iter(c.keys()))}={next(iter(c.values()))}" cookies = f"--cookie '{'; '.join(pairs)}'" if pairs else ""
for c in request.cookies
)
cookies = f"--cookie '{cookie}'"
curl_cmd = f"curl -X {method} {url} {data} {headers} {cookies}".strip() curl_cmd = f"curl -X {method} {url} {data} {headers} {cookies}".strip()
return " ".join(curl_cmd.split()) return " ".join(curl_cmd.split())

View File

@ -88,7 +88,7 @@ def open_in_browser(
def parse_details(self, response): def parse_details(self, response):
if "item name" not in response.body: if "item name" not in response.text:
open_in_browser(response) open_in_browser(response)
""" """
# circular imports # circular imports

View File

@ -39,7 +39,7 @@ def send_catch_log(
*arguments: TypingAny, *arguments: TypingAny,
**named: TypingAny, **named: TypingAny,
) -> list[tuple[TypingAny, TypingAny]]: ) -> list[tuple[TypingAny, TypingAny]]:
"""Like ``pydispatcher.robust.sendRobust()`` but it also logs errors and returns """Like ``pydispatch.robust.sendRobust()`` but it also logs errors and returns
Failures instead of exceptions. Failures instead of exceptions.
""" """
dont_log = named.pop("dont_log", ()) dont_log = named.pop("dont_log", ())
@ -125,13 +125,11 @@ def _send_catch_log_deferred(
**named, **named,
) )
d.addErrback(logerror, receiver) d.addErrback(logerror, receiver)
# TODO https://pylint.readthedocs.io/en/latest/user_guide/messages/warning/cell-var-from-loop.html
d2: Deferred[tuple[TypingAny, TypingAny]] = d.addBoth( d2: Deferred[tuple[TypingAny, TypingAny]] = d.addBoth(
lambda result: ( lambda result, recv: (recv, result), receiver
receiver, # pylint: disable=cell-var-from-loop # noqa: B023
result,
)
) )
dfds.append(d2) dfds.append(d2)
results = yield DeferredList(dfds) results = yield DeferredList(dfds)
@ -174,9 +172,8 @@ async def _send_catch_log_asyncio(
Returns a coroutine that completes once all signal handlers have finished. Returns a coroutine that completes once all signal handlers have finished.
This function requires This function requires an installed asyncio reactor or a running asyncio
:class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor` to be event loop.
installed.
.. versionadded:: 2.14 .. versionadded:: 2.14
""" """

View File

@ -97,10 +97,11 @@ class Sitemap:
@staticmethod @staticmethod
def _get_tag_name(elem: lxml.etree._Element) -> str: def _get_tag_name(elem: lxml.etree._Element) -> str:
if TYPE_CHECKING: tag = elem.tag
assert isinstance(elem.tag, str) if not isinstance(tag, str):
_, _, localname = elem.tag.partition("}") return ""
return localname or elem.tag _, _, localname = tag.partition("}")
return localname or tag
def sitemap_urls_from_robots( def sitemap_urls_from_robots(

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import logging import logging
import ssl import ssl
import warnings
from typing import TYPE_CHECKING, Any, TypedDict, TypeVar from typing import TYPE_CHECKING, Any, TypedDict, TypeVar
import OpenSSL._util as pyOpenSSLutil import OpenSSL._util as pyOpenSSLutil
@ -9,7 +10,11 @@ import OpenSSL.SSL
import OpenSSL.version import OpenSSL.version
from twisted.internet.ssl import CertificateOptions, TLSVersion from twisted.internet.ssl import CertificateOptions, TLSVersion
from scrapy.utils._deps_compat import TWISTED_TLS_LIMITS_OFFBY1 from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.utils._deps_compat import (
PYOPENSSL_X509_DEPRECATED,
TWISTED_TLS_LIMITS_OFFBY1,
)
from scrapy.utils.python import to_unicode from scrapy.utils.python import to_unicode
if TYPE_CHECKING: if TYPE_CHECKING:
@ -116,23 +121,42 @@ def _log_sslobj_debug_info(sslobj: ssl.SSLObject) -> None:
# pyOpenSSL utils # pyOpenSSL utils
def ffi_buf_to_string(buf: Any) -> str: def _ffi_buf_to_string(buf: Any) -> str:
return to_unicode(pyOpenSSLutil.ffi.string(buf)) return to_unicode(pyOpenSSLutil.ffi.string(buf))
def x509name_to_string(x509name: X509Name) -> str: def ffi_buf_to_string(buf: Any) -> str: # pragma: no cover
warnings.warn(
"ffi_buf_to_string() is deprecated.",
ScrapyDeprecationWarning,
stacklevel=2,
)
return ffi_buf_to_string(buf)
def _x509name_to_string(x509name: X509Name) -> str:
# from OpenSSL.crypto.X509Name.__repr__ # from OpenSSL.crypto.X509Name.__repr__
# only used on pyOpenSSL < 24.3.0
result_buffer: Any = pyOpenSSLutil.ffi.new("char[]", 512) result_buffer: Any = pyOpenSSLutil.ffi.new("char[]", 512)
pyOpenSSLutil.lib.X509_NAME_oneline( pyOpenSSLutil.lib.X509_NAME_oneline(
x509name._name, result_buffer, len(result_buffer) x509name._name, result_buffer, len(result_buffer)
) )
return ffi_buf_to_string(result_buffer) return _ffi_buf_to_string(result_buffer)
def get_temp_key_info(ssl_object: Any) -> str | None: def x509name_to_string(x509name: X509Name) -> str: # pragma: no cover
warnings.warn(
"x509name_to_string() is deprecated.",
ScrapyDeprecationWarning,
stacklevel=2,
)
return _x509name_to_string(x509name)
def _get_temp_key_info(ssl_object: Any) -> str | None:
# adapted from OpenSSL apps/s_cb.c::ssl_print_tmp_key() # adapted from OpenSSL apps/s_cb.c::ssl_print_tmp_key()
if not hasattr(pyOpenSSLutil.lib, "SSL_get_server_tmp_key"): if not hasattr(pyOpenSSLutil.lib, "SSL_get_server_tmp_key"):
# removed in cryptography 40.0.0 # removed in cryptography 40.0.0 (required starting from pyOpenSSL 23.1.0)
return None return None
temp_key_p = pyOpenSSLutil.ffi.new("EVP_PKEY **") temp_key_p = pyOpenSSLutil.ffi.new("EVP_PKEY **")
if not pyOpenSSLutil.lib.SSL_get_server_tmp_key(ssl_object, temp_key_p): if not pyOpenSSLutil.lib.SSL_get_server_tmp_key(ssl_object, temp_key_p):
@ -157,13 +181,22 @@ def get_temp_key_info(ssl_object: Any) -> str | None:
cname = pyOpenSSLutil.lib.EC_curve_nid2nist(nid) cname = pyOpenSSLutil.lib.EC_curve_nid2nist(nid)
if cname == pyOpenSSLutil.ffi.NULL: if cname == pyOpenSSLutil.ffi.NULL:
cname = pyOpenSSLutil.lib.OBJ_nid2sn(nid) cname = pyOpenSSLutil.lib.OBJ_nid2sn(nid)
key_info.append(ffi_buf_to_string(cname)) key_info.append(_ffi_buf_to_string(cname))
else: else:
key_info.append(ffi_buf_to_string(pyOpenSSLutil.lib.OBJ_nid2sn(key_type))) key_info.append(_ffi_buf_to_string(pyOpenSSLutil.lib.OBJ_nid2sn(key_type)))
key_info.append(f"{pyOpenSSLutil.lib.EVP_PKEY_bits(temp_key)} bits") key_info.append(f"{pyOpenSSLutil.lib.EVP_PKEY_bits(temp_key)} bits")
return ", ".join(key_info) return ", ".join(key_info)
def get_temp_key_info(ssl_object: Any) -> str | None: # pragma: no cover
warnings.warn(
"get_temp_key_info() is deprecated. It's also a no-op with cryptography 40.0.0+.",
ScrapyDeprecationWarning,
stacklevel=2,
)
return _get_temp_key_info(ssl_object)
def get_openssl_version() -> str: def get_openssl_version() -> str:
system_openssl_bytes = OpenSSL.SSL.SSLeay_version(OpenSSL.SSL.SSLEAY_VERSION) system_openssl_bytes = OpenSSL.SSL.SSLeay_version(OpenSSL.SSL.SSLEAY_VERSION)
system_openssl = system_openssl_bytes.decode("ascii", errors="replace") system_openssl = system_openssl_bytes.decode("ascii", errors="replace")
@ -177,14 +210,21 @@ def _log_ssl_conn_debug_info(hostname: str, connection: OpenSSL.SSL.Connection)
connection.get_protocol_version_name(), connection.get_protocol_version_name(),
connection.get_cipher_name(), connection.get_cipher_name(),
) )
server_cert = connection.get_peer_certificate() if PYOPENSSL_X509_DEPRECATED:
if server_cert: if server_cert := connection.get_peer_certificate(as_cryptography=True):
logger.debug( logger.debug(
'SSL connection certificate: issuer "%s", subject "%s"', 'SSL connection certificate: issuer "%s", subject "%s"',
x509name_to_string(server_cert.get_issuer()), server_cert.issuer.rfc4514_string(),
x509name_to_string(server_cert.get_subject()), server_cert.subject.rfc4514_string(),
) )
key_info = get_temp_key_info(connection._ssl) else: # noqa: PLR5501
if server_cert_pyopenssl := connection.get_peer_certificate():
logger.debug(
'SSL connection certificate: issuer "%s", subject "%s"',
_x509name_to_string(server_cert_pyopenssl.get_issuer()),
_x509name_to_string(server_cert_pyopenssl.get_subject()),
)
key_info = _get_temp_key_info(connection._ssl)
if key_info: if key_info:
logger.debug("SSL temp key: %s", key_info) logger.debug("SSL temp key: %s", key_info)

View File

@ -69,6 +69,7 @@ def get_crawler(
# When needed, useful settings can be added here, e.g. ones that prevent # When needed, useful settings can be added here, e.g. ones that prevent
# deprecation warnings. # deprecation warnings.
settings: dict[str, Any] = { settings: dict[str, Any] = {
"TELNETCONSOLE_ENABLED": False,
**get_reactor_settings(), **get_reactor_settings(),
**(settings_dict or {}), **(settings_dict or {}),
} }

View File

@ -4,9 +4,7 @@ references to live object instances.
If you want live objects for a particular class to be tracked, you only have to If you want live objects for a particular class to be tracked, you only have to
subclass from object_ref (instead of object). subclass from object_ref (instead of object).
About performance: This library has a minimal performance impact when enabled, This library has a minimal performance impact.
and no performance penalty at all when disabled (as object_ref becomes just an
alias to object in that case).
.. note:: PyPy uses a tracing garbage collector, so objects may .. note:: PyPy uses a tracing garbage collector, so objects may
remain in the ``live_refs`` longer than expected, even after they remain in the ``live_refs`` longer than expected, even after they

View File

@ -117,8 +117,8 @@ def strip_url(
- ``strip_credentials`` removes "user:password@" - ``strip_credentials`` removes "user:password@"
- ``strip_default_port`` removes ":80" (resp. ":443", ":21") - ``strip_default_port`` removes ":80" (resp. ":443", ":21")
from http:// (resp. https://, ftp://) URLs from http:// (resp. https://, ftp://) URLs
- ``origin_only`` replaces path component with "/", also dropping - ``origin_only`` replaces the path component with "/", also dropping
query and fragment components ; it also strips credentials the query component; it also strips credentials
- ``strip_fragment`` drops any #fragment component - ``strip_fragment`` drops any #fragment component
""" """
@ -139,7 +139,8 @@ def strip_url(
("ftp", 21), ("ftp", 21),
} }
): ):
netloc = netloc.replace(f":{parsed_url.port}", "") port_suffix = f":{parsed_url.port}"
netloc = netloc.removesuffix(port_suffix)
return urlunparse( return urlunparse(
( (

View File

@ -0,0 +1,31 @@
import asyncio
import sys
from twisted.internet import asyncioreactor
import scrapy
from scrapy.crawler import AsyncCrawlerProcess
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
loop = asyncio.SelectorEventLoop()
asyncio.set_event_loop(loop)
asyncioreactor.install(loop)
class NoRequestsSpider(scrapy.Spider):
name = "no_request"
async def start(self):
return
yield
process = AsyncCrawlerProcess(
settings={
"TWISTED_REACTOR": "twisted.internet.asyncioreactor.AsyncioSelectorReactor",
"ASYNCIO_EVENT_LOOP": "asyncio.SelectorEventLoop",
}
)
process.crawl(NoRequestsSpider)
process.start()

View File

@ -0,0 +1,31 @@
import sys
import scrapy
from scrapy.crawler import AsyncCrawlerProcess
from scrapy.settings import Settings
class NoRequestsSpider(scrapy.Spider):
name = "no_request"
async def start(self):
return
yield
settings = Settings()
# The deprecated DNS_RESOLVER setting, set above its default priority so that
# AsyncCrawlerProcess._setup_reactor() emits the deprecation warning.
settings.set("DNS_RESOLVER", "scrapy.resolver.CachingThreadedResolver", priority=10)
if len(sys.argv) > 1 and sys.argv[1] == "twisted-wins":
# TWISTED_DNS_RESOLVER at a higher priority takes precedence over the
# deprecated DNS_RESOLVER setting.
settings.set(
"TWISTED_DNS_RESOLVER",
"scrapy.resolver.CachingThreadedResolver",
priority=20,
)
process = AsyncCrawlerProcess(settings)
process.crawl(NoRequestsSpider)
process.start()

View File

@ -17,4 +17,4 @@ class SleepingSpider(scrapy.Spider):
process = AsyncCrawlerProcess(settings={"TWISTED_REACTOR_ENABLED": False}) process = AsyncCrawlerProcess(settings={"TWISTED_REACTOR_ENABLED": False})
process.crawl(SleepingSpider) process.crawl(SleepingSpider)
process.start() process.start(stop_after_crawl="--no-stop" not in sys.argv)

View File

@ -17,4 +17,4 @@ class SleepingSpider(scrapy.Spider):
process = AsyncCrawlerProcess(settings={}) process = AsyncCrawlerProcess(settings={})
process.crawl(SleepingSpider) process.crawl(SleepingSpider)
process.start() process.start(stop_after_crawl="--no-stop" not in sys.argv)

View File

@ -0,0 +1,31 @@
import sys
import scrapy
from scrapy.crawler import CrawlerProcess
from scrapy.settings import Settings
class NoRequestsSpider(scrapy.Spider):
name = "no_request"
async def start(self):
return
yield
settings = Settings()
# The deprecated DNS_RESOLVER setting, set above its default priority so that
# CrawlerProcess._setup_reactor() emits the deprecation warning.
settings.set("DNS_RESOLVER", "scrapy.resolver.CachingThreadedResolver", priority=10)
if len(sys.argv) > 1 and sys.argv[1] == "twisted-wins":
# TWISTED_DNS_RESOLVER at a higher priority takes precedence over the
# deprecated DNS_RESOLVER setting.
settings.set(
"TWISTED_DNS_RESOLVER",
"scrapy.resolver.CachingThreadedResolver",
priority=20,
)
process = CrawlerProcess(settings)
process.crawl(NoRequestsSpider)
process.start()

View File

@ -23,4 +23,4 @@ class SleepingSpider(scrapy.Spider):
process = CrawlerProcess(settings={}) process = CrawlerProcess(settings={})
process.crawl(SleepingSpider) process.crawl(SleepingSpider)
process.start() process.start(stop_after_crawl="--no-stop" not in sys.argv)

View File

@ -1,3 +1,3 @@
scrapy/downloadermiddlewares/cookies.py scrapy/core/downloader/handlers/http.py
scrapy/extensions/statsmailer.py scrapy/extensions/statsmailer.py
scrapy/extensions/memusage.py scrapy/mail.py

View File

@ -33,7 +33,7 @@ class SimpleMockServer(BaseMockServer):
super().__init__() super().__init__()
self.keyfile = keyfile self.keyfile = keyfile
self.certfile = certfile self.certfile = certfile
self.cipher_string = cipher_string or "" self.cipher_string = cipher_string
self.tls_min_version = tls_min_version self.tls_min_version = tls_min_version
self.tls_max_version = tls_max_version self.tls_max_version = tls_max_version

View File

@ -10,7 +10,7 @@ from OpenSSL.crypto import FILETYPE_PEM, load_certificate, load_privatekey
from twisted.internet.ssl import CertificateOptions, ContextFactory from twisted.internet.ssl import CertificateOptions, ContextFactory
from scrapy.core.downloader.tls import _TWISTED_VERSION_MAP from scrapy.core.downloader.tls import _TWISTED_VERSION_MAP
from scrapy.utils._deps_compat import PYOPENSSL_WANTS_X509_PKEY from scrapy.utils._deps_compat import PYOPENSSL_X509_DEPRECATED
from scrapy.utils.python import to_bytes from scrapy.utils.python import to_bytes
from scrapy.utils.ssl import _get_cert_options_version_kwargs from scrapy.utils.ssl import _get_cert_options_version_kwargs
@ -29,7 +29,7 @@ def ssl_context_factory(
keyfile_path = Path(__file__).parent.parent / keyfile keyfile_path = Path(__file__).parent.parent / keyfile
certfile_path = Path(__file__).parent.parent / certfile certfile_path = Path(__file__).parent.parent / certfile
if not PYOPENSSL_WANTS_X509_PKEY: if PYOPENSSL_X509_DEPRECATED:
cert = load_pem_x509_certificate(certfile_path.read_bytes()) cert = load_pem_x509_certificate(certfile_path.read_bytes())
key = load_pem_private_key(keyfile_path.read_bytes(), password=None) key = load_pem_private_key(keyfile_path.read_bytes(), password=None)
else: else:

View File

@ -222,7 +222,7 @@ class AsyncDefDeferredWrappedSpider(SimpleSpider):
class AsyncDefDeferredMaybeWrappedSpider(SimpleSpider): class AsyncDefDeferredMaybeWrappedSpider(SimpleSpider):
name = "asyncdef_deferred_wrapped" name = "asyncdef_deferred_maybe_wrapped"
async def parse(self, response): async def parse(self, response):
await maybe_deferred_to_future(defer.succeed(None)) await maybe_deferred_to_future(defer.succeed(None))

View File

@ -161,6 +161,7 @@ class TestAddonManager:
settings.set("KEY", 0, priority="default") settings.set("KEY", 0, priority="default")
runner = runner_cls(settings) runner = runner_cls(settings)
crawler = runner.create_crawler(Spider) crawler = runner.create_crawler(Spider)
crawler._apply_settings()
assert crawler.settings.getint("KEY") == 20 assert crawler.settings.getint("KEY") == 20
def test_fallback_workflow(self): def test_fallback_workflow(self):

View File

@ -1,14 +1,2 @@
"""A test extension used to check the settings loading order"""
class TestExtension:
def __init__(self, settings):
settings.set("TEST1", f"{settings['TEST1']} + started")
@classmethod
def from_crawler(cls, crawler):
return cls(crawler.settings)
class DummyExtension: class DummyExtension:
pass pass

View File

@ -1,7 +1,7 @@
from pathlib import Path from pathlib import Path
EXTENSIONS = { EXTENSIONS = {
"tests.test_cmdline.extensions.TestExtension": 0, "tests.test_cmdline.extensions.DummyExtension": 0,
} }
TEST1 = "default" TEST1 = "default"

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import re import re
import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -9,6 +10,13 @@ from tests.test_commands import TestProjectBase
from tests.utils.cmdline import call, proc from tests.utils.cmdline import call, proc
def write_recording_editor(editor: Path) -> None:
"""Create an executable editor script that writes the path it is asked to
open (its last argument) into the file given as its first argument."""
editor.write_text('#!/bin/sh\nprintf "%s" "$2" > "$1"\n', encoding="utf-8")
editor.chmod(0o755)
def find_in_file(filename: Path, regex: str) -> re.Match[str] | None: def find_in_file(filename: Path, regex: str) -> re.Match[str] | None:
"""Find first pattern occurrence in file""" """Find first pattern occurrence in file"""
pattern = re.compile(regex) pattern = re.compile(regex)
@ -63,6 +71,28 @@ class TestGenspiderCommand(TestProjectBase):
assert call("genspider", "--dump=basic", cwd=proj_path) == 0 assert call("genspider", "--dump=basic", cwd=proj_path) == 0
assert call("genspider", "-d", "basic", cwd=proj_path) == 0 assert call("genspider", "-d", "basic", cwd=proj_path) == 0
@pytest.mark.skipif(
sys.platform == "win32", reason="requires a POSIX shell editor script"
)
def test_edit(self, proj_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
spider = proj_path / self.project_name / "spiders" / "example2.py"
edited = proj_path / "edited.txt"
editor = proj_path / "fake-editor.sh"
write_recording_editor(editor)
# The extra argument exercises shlex-splitting of the EDITOR value.
monkeypatch.setenv("EDITOR", f"{editor} {edited}")
returncode, _, err = proc(
"genspider", "--edit", "example2", "example2.com", cwd=proj_path
)
assert returncode == 0, err
assert "ModuleNotFoundError" not in err
assert spider.exists()
assert (proj_path / edited.read_text(encoding="utf-8")).resolve() == (
spider.resolve()
)
def test_same_name_as_project(self, proj_path: Path) -> None: def test_same_name_as_project(self, proj_path: Path) -> None:
assert call("genspider", self.project_name, cwd=proj_path) == 2 assert call("genspider", self.project_name, cwd=proj_path) == 2
assert not ( assert not (
@ -168,6 +198,26 @@ class TestGenspiderStandaloneCommand:
call("genspider", "example", "example.com", cwd=tmp_path) call("genspider", "example", "example.com", cwd=tmp_path)
assert Path(tmp_path, "example.py").exists() assert Path(tmp_path, "example.py").exists()
@pytest.mark.skipif(
sys.platform == "win32", reason="requires a POSIX shell editor script"
)
def test_edit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
spider = tmp_path / "example.py"
edited = tmp_path / "edited.txt"
editor = tmp_path / "fake-editor.sh"
write_recording_editor(editor)
monkeypatch.setenv("EDITOR", f"{editor} {edited}")
returncode, _, err = proc(
"genspider", "--edit", "example", "example.com", cwd=tmp_path
)
assert returncode == 0, err
assert spider.exists()
assert (tmp_path / edited.read_text(encoding="utf-8")).resolve() == (
spider.resolve()
)
@pytest.mark.parametrize("force", [True, False]) @pytest.mark.parametrize("force", [True, False])
def test_same_name_as_existing_file(self, force: bool, tmp_path: Path) -> None: def test_same_name_as_existing_file(self, force: bool, tmp_path: Path) -> None:
file_name = "example" file_name = "example"

Some files were not shown because too many files have changed in this diff Show More