From ba28630c981203e996114cfc4f12b484d2f021e2 Mon Sep 17 00:00:00 2001 From: Syncrain <71864702+syncrain@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:17:00 +0530 Subject: [PATCH 01/11] Validate reversed telnet console port ranges (#7593) --- scrapy/utils/reactor.py | 2 ++ tests/test_extension_telnet.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/scrapy/utils/reactor.py b/scrapy/utils/reactor.py index 7ab58093a..c60bb215d 100644 --- a/scrapy/utils/reactor.py +++ b/scrapy/utils/reactor.py @@ -33,6 +33,8 @@ def listen_tcp(portrange: list[int], host: str, factory: ServerFactory) -> Port: if len(portrange) > 2: raise ValueError(f"invalid portrange: {portrange}") + if len(portrange) == 2 and portrange[0] > portrange[1]: + raise ValueError(f"invalid portrange: {portrange}") if not portrange: return reactor.listenTCP(0, factory, interface=host) # type: ignore[no-any-return] if len(portrange) == 1: diff --git a/tests/test_extension_telnet.py b/tests/test_extension_telnet.py index f1c86ce62..3f9135867 100644 --- a/tests/test_extension_telnet.py +++ b/tests/test_extension_telnet.py @@ -53,3 +53,9 @@ class TestTelnetExtension: d = portal.login(creds, None, ITelnetProtocol) yield d console.stop_listening() + + def test_invalid_reversed_portrange(self): + settings = {"TELNETCONSOLE_PORT": [2, 1]} + console = TelnetConsole(get_crawler(settings_dict=settings)) + with pytest.raises(ValueError, match=r"invalid portrange: \[2, 1\]"): + console.start_listening() From ad4549673bdc1a99175ca478ab3a2fea1d4a8a55 Mon Sep 17 00:00:00 2001 From: msn <82942483+msn-2006@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:15:25 +0530 Subject: [PATCH 02/11] Remove set_asyncio_event_loop call from Shell._schedule (#7594) Co-authored-by: Mayuresh --- scrapy/shell.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/scrapy/shell.py b/scrapy/shell.py index 0bd71feb2..8bb5994e3 100644 --- a/scrapy/shell.py +++ b/scrapy/shell.py @@ -30,7 +30,6 @@ from scrapy.utils.console import DEFAULT_PYTHON_SHELLS, start_python_console from scrapy.utils.datatypes import SequenceExclude from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future from scrapy.utils.misc import load_object -from scrapy.utils.reactor import is_asyncio_reactor_installed, set_asyncio_event_loop from scrapy.utils.response import open_in_browser if TYPE_CHECKING: @@ -191,10 +190,6 @@ class Shell: Runs in the reactor thread. """ - if self._use_reactor and is_asyncio_reactor_installed(): - # set the asyncio event loop for the current thread - event_loop_path = self.crawler.settings["ASYNCIO_EVENT_LOOP"] - set_asyncio_event_loop(event_loop_path) if not self.spider: await self._open_spider(spider) assert self.crawler.engine is not None From 4cb049cb15207de0967e4c2b0ff2527e130f5662 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 11 Jun 2026 13:51:03 +0500 Subject: [PATCH 03/11] Small docs fixes. (#7598) --- docs/topics/coroutines.rst | 4 ++-- docs/topics/extensions.rst | 4 ++-- docs/topics/request-response.rst | 2 +- docs/topics/settings.rst | 3 +-- docs/topics/telnetconsole.rst | 2 -- scrapy/loader/__init__.py | 2 +- scrapy/shell.py | 4 ++-- scrapy/utils/reactor.py | 2 +- 8 files changed, 10 insertions(+), 13 deletions(-) diff --git a/docs/topics/coroutines.rst b/docs/topics/coroutines.rst index ba68f0dbc..116aac323 100644 --- a/docs/topics/coroutines.rst +++ b/docs/topics/coroutines.rst @@ -267,6 +267,6 @@ You can also send multiple requests in parallel: responses = await asyncio.gather(*tasks) yield { "h1": response.css("h1::text").get(), - "price": responses[0][1].css(".price::text").get(), - "price2": responses[1][1].css(".color::text").get(), + "price": responses[0].css(".price::text").get(), + "price2": responses[1].css(".color::text").get(), } diff --git a/docs/topics/extensions.rst b/docs/topics/extensions.rst index 735e46c29..5a05f67d9 100644 --- a/docs/topics/extensions.rst +++ b/docs/topics/extensions.rst @@ -149,8 +149,6 @@ The following stats are collected: (e.g. ``item_dropped_reasons_count/DropItem``). * ``response_received_count``: total number of HTTP responses received. -.. _topics-extensions-ref-telnetconsole: - Log Count extension ~~~~~~~~~~~~~~~~~~~ @@ -159,6 +157,8 @@ Log Count extension .. autoclass:: LogCount +.. _topics-extensions-ref-telnetconsole: + Telnet console extension ~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index 8fd3de621..a0fff6acb 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -979,7 +979,7 @@ Response objects A dictionary-like (:class:`scrapy.http.headers.Headers`) object which contains the response headers. Values can be accessed using - :meth:`~scrapy.http.headers.Headers.get` to return the first header value with + :meth:`~scrapy.http.headers.Headers.get` to return the last header value with the specified name or :meth:`~scrapy.http.headers.Headers.getlist` to return all header values with the specified name. For example, this call will give you all cookies in the headers:: diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst index 06de33e6f..455985a56 100644 --- a/docs/topics/settings.rst +++ b/docs/topics/settings.rst @@ -734,8 +734,7 @@ specific cipher that is not included in ``DEFAULT`` if a website requires it. Handling of this setting needs to be implemented inside the :ref:`download handler `, so it's not guaranteed to be supported - by all 3rd-party handlers. It's currently unsupported by - :class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`. + by all 3rd-party handlers. .. setting:: DOWNLOAD_TLS_MAX_VERSION diff --git a/docs/topics/telnetconsole.rst b/docs/topics/telnetconsole.rst index 6d99c756e..e274edc9b 100644 --- a/docs/topics/telnetconsole.rst +++ b/docs/topics/telnetconsole.rst @@ -94,8 +94,6 @@ convenience: +----------------+-------------------------------------------------------------------+ | ``p`` | a shortcut to the :func:`pprint.pprint` function | +----------------+-------------------------------------------------------------------+ -| ``hpy`` | for memory debugging (see :ref:`topics-leaks`) | -+----------------+-------------------------------------------------------------------+ Telnet console usage examples ============================= diff --git a/scrapy/loader/__init__.py b/scrapy/loader/__init__.py index 2f5c0343b..8ee7dc16c 100644 --- a/scrapy/loader/__init__.py +++ b/scrapy/loader/__init__.py @@ -27,7 +27,7 @@ class ItemLoader(itemloaders.ItemLoader): :param item: The item instance to populate using subsequent calls to :meth:`~ItemLoader.add_xpath`, :meth:`~ItemLoader.add_css`, or :meth:`~ItemLoader.add_value`. - :type item: scrapy.item.Item + :type item: :ref:`item object ` :param selector: The selector to extract data from, when using the :meth:`add_xpath`, :meth:`add_css`, :meth:`replace_xpath`, or diff --git a/scrapy/shell.py b/scrapy/shell.py index 8bb5994e3..44dcd880e 100644 --- a/scrapy/shell.py +++ b/scrapy/shell.py @@ -75,8 +75,8 @@ if TYPE_CHECKING: # running event loop. # # Side note: it should be possible to remove _request_deferred() by using -# engine.download() instead of engine.schedule(), losing the usual stuff like -# spider middlewares (none of which should be important). +# engine.download_async() instead of engine.schedule(), losing the usual stuff +# like spider middlewares (none of which should be important). # # Other architecture problems: # * scrapy.cmdline.execute() creates an AsyncCrawlerProcess instance which diff --git a/scrapy/utils/reactor.py b/scrapy/utils/reactor.py index c60bb215d..5499e3f48 100644 --- a/scrapy/utils/reactor.py +++ b/scrapy/utils/reactor.py @@ -187,7 +187,7 @@ def verify_installed_reactor(reactor_path: str) -> None: def verify_installed_asyncio_event_loop(loop_path: str) -> None: - """Raise :exc:`RuntimeError` if the even loop of the installed + """Raise :exc:`RuntimeError` if the event loop of the installed :class:`~twisted.internet.asyncioreactor.AsyncioSelectorReactor` does not match the specified import path or if no reactor is installed.""" if not is_reactor_installed(): From e7f229f5b2efa7168f4240cbe5971bc4ab5ef151 Mon Sep 17 00:00:00 2001 From: Adrian Date: Thu, 11 Jun 2026 10:51:46 +0200 Subject: [PATCH 04/11] Clarify the first-class-citizen nature of asyncio support (#7599) --- docs/topics/asyncio.rst | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/topics/asyncio.rst b/docs/topics/asyncio.rst index 8efa2559e..1a151044f 100644 --- a/docs/topics/asyncio.rst +++ b/docs/topics/asyncio.rst @@ -4,19 +4,27 @@ asyncio ======= -Scrapy has partial support for :mod:`asyncio`. After you :ref:`install the -asyncio reactor `, you may use :mod:`asyncio` and -:mod:`asyncio`-powered libraries in any :doc:`coroutine `. +Scrapy supports :mod:`asyncio` natively. New projects created with +:command:`scrapy startproject` have asyncio enabled by default, and you can use +:mod:`asyncio` and :mod:`asyncio`-powered libraries in any :doc:`coroutine +`. + +The rest of this page covers advanced topics. If you are starting a new project, +no additional setup is needed. .. _install-asyncio: -Installing the asyncio reactor -============================== +Configuring the asyncio reactor +=============================== -To enable :mod:`asyncio` support, your :setting:`TWISTED_REACTOR` setting needs -to be set to ``'twisted.internet.asyncioreactor.AsyncioSelectorReactor'``, -which is the default value. +New projects generated with :command:`scrapy startproject` have the asyncio +reactor configured by default. No manual setup is needed. + +The :setting:`TWISTED_REACTOR` setting controls which Twisted reactor Scrapy +uses. Its default value is +``'twisted.internet.asyncioreactor.AsyncioSelectorReactor'``, which enables +:mod:`asyncio` support. If you are using :class:`~scrapy.crawler.AsyncCrawlerRunner` or :class:`~scrapy.crawler.CrawlerRunner`, you also need to From e0a7de7213e2f06a94917624bad572a41dcc3905 Mon Sep 17 00:00:00 2001 From: Labib Bin Salam <98468420+Labib-Bin-Salam@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:40:15 +0100 Subject: [PATCH 05/11] Document cb_kwargs/meta deep-copy when JOBDIR is set (#7573) When JOBDIR is enabled, requests are serialized to disk with pickle, so the objects stored in a request's cb_kwargs and meta are deep-copied on the round trip. Callbacks then receive copies rather than the original objects, which is easy to miss and can silently break code that relies on sharing mutable state. Add a note to the request serialization section of the jobs docs and a cross-referenced caution to the Request.cb_kwargs attribute docs. Closes #6120 --- docs/topics/jobs.rst | 9 +++++++++ docs/topics/request-response.rst | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/docs/topics/jobs.rst b/docs/topics/jobs.rst index 769925dd5..8d6b2458b 100644 --- a/docs/topics/jobs.rst +++ b/docs/topics/jobs.rst @@ -104,6 +104,15 @@ If you wish to log the requests that couldn't be serialized, you can set the :setting:`SCHEDULER_DEBUG` setting to ``True`` in the project's settings page. It is ``False`` by default. +.. note:: Because requests are serialized with :mod:`pickle`, the objects you + store on a request, such as the values of its + :attr:`~scrapy.Request.cb_kwargs` and :attr:`~scrapy.Request.meta` + dictionaries, are deep-copied when the request is written to and later read + back from the job directory. As a result, the callback receives a *copy* of + those objects rather than the original ones, and changes made to the copy are + not reflected in the original object. Keep this in mind if you rely on + sharing mutable state through ``cb_kwargs`` or ``meta``. + .. _job-dir-contents: Job directory contents diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index a0fff6acb..67b435067 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -188,6 +188,13 @@ Request objects ``failure.request.cb_kwargs`` in the request's errback. For more information, see :ref:`errback-cb_kwargs`. + .. note:: When :setting:`JOBDIR` is set, requests are serialized to disk + with :mod:`pickle` (see :ref:`request-serialization`). As a result, + the callback receives a deep copy of any object stored in + ``cb_kwargs``, so mutating such an object in the callback does not + affect the original. Avoid relying on shared mutable state passed + through ``cb_kwargs`` in that case. + .. attribute:: Request.meta :value: {} From c99f6e22094ef1ba65a2015c10badcb685779a55 Mon Sep 17 00:00:00 2001 From: Adrian Date: Thu, 11 Jun 2026 12:10:49 +0200 Subject: [PATCH 06/11] Document the SPIDER_MODULES hack to lower startup time and memory usage (#7600) --- docs/topics/practices.rst | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/topics/practices.rst b/docs/topics/practices.rst index 11c2656da..b3c58d6d3 100644 --- a/docs/topics/practices.rst +++ b/docs/topics/practices.rst @@ -387,6 +387,26 @@ crawl:: curl http://scrapy2.mycompany.com:6800/schedule.json -d project=myproject -d spider=spider1 -d part=2 curl http://scrapy3.mycompany.com:6800/schedule.json -d project=myproject -d spider=spider1 -d part=3 +.. _large-project-startup: + +Reducing startup time in large projects +======================================= + +When running a spider with ``scrapy crawl``, Scrapy loads all modules listed in +:setting:`SPIDER_MODULES` to find the target spider. In large projects with +many spiders, this can noticeably increase startup time and memory usage. + +To avoid loading every spider module, override :setting:`SPIDER_MODULES` on the +command line to point only to the module that contains the spider you want to +run: + +.. code-block:: shell + + scrapy crawl myspider -s SPIDER_MODULES=myproject.spiders.myspider + +Because :setting:`SPIDER_MODULES` is a list setting, you can include multiple +modules by separating them with commas. + .. _bans: Avoiding getting banned From a8ffdcf8517a8973391a14635234b6993b15a86a Mon Sep 17 00:00:00 2001 From: Adrian Date: Thu, 11 Jun 2026 12:56:53 +0200 Subject: [PATCH 07/11] Implement HTTP Auth settings and request metadata keys (#7590) * Implement HTTP Auth settings and request metadata keys * Fix doc-tests * Require the domain to be set when using settings --- docs/topics/downloader-middleware.rst | 80 +++++++--- docs/topics/request-response.rst | 24 +++ docs/topics/spiders.rst | 5 - scrapy/downloadermiddlewares/httpauth.py | 55 +++++-- scrapy/settings/default_settings.py | 7 + tests/test_downloadermiddleware_httpauth.py | 167 +++++++++++++++----- 6 files changed, 264 insertions(+), 74 deletions(-) diff --git a/docs/topics/downloader-middleware.rst b/docs/topics/downloader-middleware.rst index 8cb29deff..0c1af5276 100644 --- a/docs/topics/downloader-middleware.rst +++ b/docs/topics/downloader-middleware.rst @@ -307,26 +307,15 @@ HttpAuthMiddleware .. class:: HttpAuthMiddleware - This middleware authenticates all requests generated from certain spiders - using `Basic access authentication`_ (aka. HTTP auth). + This middleware authenticates requests using `Basic access authentication`_ + (aka. HTTP auth). - To enable HTTP authentication for a spider, set the ``http_user`` and - ``http_pass`` spider attributes to the authentication data and the - ``http_auth_domain`` spider attribute to the domain which requires this - authentication (its subdomains will be also handled in the same way). - You can set ``http_auth_domain`` to ``None`` to enable the - authentication for all requests but you risk leaking your authentication - credentials to unrelated domains. + Use the :setting:`HTTPAUTH_USER`, :setting:`HTTPAUTH_PASS`, and + :setting:`HTTPAUTH_DOMAIN` settings to configure it. You can also override + the credentials per request via :attr:`~scrapy.Request.meta` keys + :reqmeta:`http_user`, :reqmeta:`http_pass`, and :reqmeta:`http_auth_domain`. - .. warning:: - In previous Scrapy versions HttpAuthMiddleware sent the authentication - data with all requests, which is a security problem if the spider - makes requests to several different domains. Currently if the - ``http_auth_domain`` attribute is not set, the middleware will use the - domain of the first request, which will work for some spiders but not - for others. In the future the middleware will produce an error instead. - - Example: + Example using settings (e.g. in :attr:`~scrapy.Spider.custom_settings`): .. code-block:: python @@ -334,13 +323,62 @@ HttpAuthMiddleware class SomeIntranetSiteSpider(CrawlSpider): - http_user = "someuser" - http_pass = "somepass" - http_auth_domain = "intranet.example.com" name = "intranet.example.com" + custom_settings = { + "HTTPAUTH_USER": "someuser", + "HTTPAUTH_PASS": "somepass", + "HTTPAUTH_DOMAIN": "intranet.example.com", + } # .. rest of the spider code omitted ... + Example using per-request meta: + + .. code-block:: python + + async def start(self): + yield Request( + "https://intranet.example.com/protected/", + meta={ + "http_user": "someuser", + "http_pass": "somepass", + "http_auth_domain": "intranet.example.com", + }, + ) + +.. setting:: HTTPAUTH_USER + +HTTPAUTH_USER +~~~~~~~~~~~~~ + +Default: ``""`` + +The username to use for HTTP basic authentication, applied to all requests +whose URL matches :setting:`HTTPAUTH_DOMAIN`. + +.. setting:: HTTPAUTH_PASS + +HTTPAUTH_PASS +~~~~~~~~~~~~~ + +Default: ``""`` + +The password to use for HTTP basic authentication. + +.. setting:: HTTPAUTH_DOMAIN + +HTTPAUTH_DOMAIN +~~~~~~~~~~~~~~~ + +Default: ``None`` + +The domain (and its subdomains) to which HTTP basic authentication credentials +are sent. Set to ``None`` to send credentials with all requests, but be aware +that this risks leaking credentials to unrelated domains. + +This setting must be explicitly configured whenever :setting:`HTTPAUTH_USER` +or :setting:`HTTPAUTH_PASS` is set. + .. _Basic access authentication: https://en.wikipedia.org/wiki/Basic_access_authentication diff --git a/docs/topics/request-response.rst b/docs/topics/request-response.rst index 67b435067..700238fe9 100644 --- a/docs/topics/request-response.rst +++ b/docs/topics/request-response.rst @@ -724,6 +724,9 @@ Those are: * :reqmeta:`give_up_log_level` * :reqmeta:`handle_httpstatus_all` * :reqmeta:`handle_httpstatus_list` +* :reqmeta:`http_auth_domain` +* :reqmeta:`http_pass` +* :reqmeta:`http_user` * :reqmeta:`is_start_request` * :reqmeta:`max_retry_times` * :reqmeta:`proxy` @@ -806,6 +809,27 @@ give_up_log_level :ref:`Logging level ` used for the message logged when a request exceeds its retries. See :setting:`RETRY_GIVE_UP_LOG_LEVEL` for details. +.. reqmeta:: http_auth_domain + +http_auth_domain +---------------- + +Overrides :setting:`HTTPAUTH_DOMAIN` for this request. + +.. reqmeta:: http_pass + +http_pass +--------- + +Overrides :setting:`HTTPAUTH_PASS` for this request. + +.. reqmeta:: http_user + +http_user +--------- + +Overrides :setting:`HTTPAUTH_USER` for this request. + .. reqmeta:: max_retry_times max_retry_times diff --git a/docs/topics/spiders.rst b/docs/topics/spiders.rst index bcef9d5f6..506daf930 100644 --- a/docs/topics/spiders.rst +++ b/docs/topics/spiders.rst @@ -354,11 +354,6 @@ Otherwise, you would cause iteration over a ``start_urls`` string (a very common python pitfall) resulting in each character being seen as a separate url. -A valid use case is to set the http auth credentials -used by :class:`~scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware`:: - - scrapy crawl myspider -a http_user=myuser -a http_pass=mypassword - Spider arguments can also be passed through the Scrapyd ``schedule.json`` API. See `Scrapyd documentation`_. diff --git a/scrapy/downloadermiddlewares/httpauth.py b/scrapy/downloadermiddlewares/httpauth.py index c28c93d4e..b09abdadd 100644 --- a/scrapy/downloadermiddlewares/httpauth.py +++ b/scrapy/downloadermiddlewares/httpauth.py @@ -6,11 +6,14 @@ See documentation in docs/topics/downloader-middleware.rst from __future__ import annotations +import warnings from typing import TYPE_CHECKING from w3lib.http import basic_auth_header from scrapy import Request, Spider, signals +from scrapy.exceptions import ScrapyDeprecationWarning +from scrapy.settings import SETTINGS_PRIORITIES from scrapy.utils.decorators import _warn_spider_arg from scrapy.utils.url import url_is_from_any_domain @@ -23,12 +26,28 @@ if TYPE_CHECKING: class HttpAuthMiddleware: - """Set Basic HTTP Authorization header - (http_user and http_pass spider class attributes)""" + """Set Basic HTTP Authorization header.""" + + def __init__(self) -> None: + self._auth: bytes | None = None + self._domain: str | None = None @classmethod def from_crawler(cls, crawler: Crawler) -> Self: o = cls() + usr = crawler.settings.get("HTTPAUTH_USER", "") + pwd = crawler.settings.get("HTTPAUTH_PASS", "") + if usr or pwd: + domain_priority = crawler.settings.getpriority("HTTPAUTH_DOMAIN") or 0 + if domain_priority <= SETTINGS_PRIORITIES["default"]: + raise ValueError( + "HTTPAUTH_DOMAIN must be set when HTTPAUTH_USER or HTTPAUTH_PASS " + "is configured. Set it to a domain (e.g. 'example.com') to restrict " + "credentials to that domain, or set it to None to send credentials " + "with all requests." + ) + o._auth = basic_auth_header(usr, pwd) + o._domain = crawler.settings.get("HTTPAUTH_DOMAIN") crawler.signals.connect(o.spider_opened, signal=signals.spider_opened) return o @@ -36,18 +55,34 @@ class HttpAuthMiddleware: usr = getattr(spider, "http_user", "") pwd = getattr(spider, "http_pass", "") if usr or pwd: - self.auth = basic_auth_header(usr, pwd) - self.domain = spider.http_auth_domain # type: ignore[attr-defined] + warnings.warn( + "Use the HTTPAUTH_USER, HTTPAUTH_PASS, and HTTPAUTH_DOMAIN settings " + "instead of the http_user, http_pass, and http_auth_domain spider " + "attributes. Support for the spider attributes will be removed in a " + "future version of Scrapy.", + category=ScrapyDeprecationWarning, + stacklevel=2, + ) + self._auth = basic_auth_header(usr, pwd) + self._domain = spider.http_auth_domain # type: ignore[attr-defined] @_warn_spider_arg def process_request( self, request: Request, spider: Spider | None = None ) -> Request | Response | None: - auth = getattr(self, "auth", None) - if ( - auth - and b"Authorization" not in request.headers - and (not self.domain or url_is_from_any_domain(request.url, [self.domain])) + if b"Authorization" in request.headers: + return None + # Per-request meta overrides + usr = request.meta.get("http_user", "") + pwd = request.meta.get("http_pass", "") + if usr or pwd: + domain = request.meta.get("http_auth_domain") + if not domain or url_is_from_any_domain(request.url, [domain]): + request.headers[b"Authorization"] = basic_auth_header(usr, pwd) + return None + # Middleware-level auth + if self._auth and ( + not self._domain or url_is_from_any_domain(request.url, [self._domain]) ): - request.headers[b"Authorization"] = auth + request.headers[b"Authorization"] = self._auth return None diff --git a/scrapy/settings/default_settings.py b/scrapy/settings/default_settings.py index e8a9400d7..049921ba7 100644 --- a/scrapy/settings/default_settings.py +++ b/scrapy/settings/default_settings.py @@ -107,6 +107,9 @@ __all__ = [ "FTP_PASSWORD", "FTP_USER", "GCS_PROJECT_ID", + "HTTPAUTH_DOMAIN", + "HTTPAUTH_PASS", + "HTTPAUTH_USER", "HTTPCACHE_ALWAYS_STORE", "HTTPCACHE_DBM_MODULE", "HTTPCACHE_DIR", @@ -397,6 +400,10 @@ FTP_PASSWORD = "guest" # noqa: S105 GCS_PROJECT_ID = None +HTTPAUTH_USER = "" +HTTPAUTH_PASS = "" +HTTPAUTH_DOMAIN = None + HTTPCACHE_ENABLED = False HTTPCACHE_ALWAYS_STORE = False HTTPCACHE_DBM_MODULE = "dbm" diff --git a/tests/test_downloadermiddleware_httpauth.py b/tests/test_downloadermiddleware_httpauth.py index 522a3002f..827133d2e 100644 --- a/tests/test_downloadermiddleware_httpauth.py +++ b/tests/test_downloadermiddleware_httpauth.py @@ -2,8 +2,25 @@ import pytest from w3lib.http import basic_auth_header from scrapy.downloadermiddlewares.httpauth import HttpAuthMiddleware +from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.http import Request from scrapy.spiders import Spider +from scrapy.utils.test import get_crawler + +_DOMAIN_NOT_SET = object() + + +def make_mw(user="", passwd="", domain=_DOMAIN_NOT_SET): + settings: dict = { + "HTTPAUTH_USER": user, + "HTTPAUTH_PASS": passwd, + } + if domain is not _DOMAIN_NOT_SET: + settings["HTTPAUTH_DOMAIN"] = domain + return HttpAuthMiddleware.from_crawler(get_crawler(settings_dict=settings)) + + +# --- Spider attribute tests (deprecated) --- class LegacySpider(Spider): @@ -23,61 +40,135 @@ class AnyDomainSpider(Spider): http_auth_domain = None -class TestHttpAuthMiddlewareLegacy: - def setup_method(self): - self.spider = LegacySpider("foo") - - def test_auth(self): +class TestHttpAuthMiddlewareLegacySpiderAttr: + def test_missing_domain_raises(self): mw = HttpAuthMiddleware() - with pytest.raises(AttributeError): - mw.spider_opened(self.spider) + with pytest.warns(ScrapyDeprecationWarning), pytest.raises(AttributeError): + mw.spider_opened(LegacySpider("foo")) + def test_domain_spider(self): + mw = HttpAuthMiddleware() + with pytest.warns(ScrapyDeprecationWarning): + mw.spider_opened(DomainSpider("foo")) + req = Request("http://example.com/") + mw.process_request(req) + assert req.headers["Authorization"] == basic_auth_header("foo", "bar") -class TestHttpAuthMiddleware: - def setup_method(self): - self.mw = HttpAuthMiddleware() - spider = DomainSpider("foo") - self.mw.spider_opened(spider) - - def teardown_method(self): - del self.mw - - def test_no_auth(self): - req = Request("http://example-noauth.com/") - assert self.mw.process_request(req) is None + def test_no_auth_wrong_domain(self): + mw = HttpAuthMiddleware() + with pytest.warns(ScrapyDeprecationWarning): + mw.spider_opened(DomainSpider("foo")) + req = Request("http://other.com/") + mw.process_request(req) assert "Authorization" not in req.headers - def test_auth_domain(self): + def test_any_domain_spider(self): + mw = HttpAuthMiddleware() + with pytest.warns(ScrapyDeprecationWarning): + mw.spider_opened(AnyDomainSpider("foo")) + req = Request("http://anywhere.com/") + mw.process_request(req) + assert req.headers["Authorization"] == basic_auth_header("foo", "bar") + + +# --- Settings-based tests --- + + +class TestHttpAuthMiddlewareSettings: + def test_no_auth(self): + mw = make_mw() req = Request("http://example.com/") - assert self.mw.process_request(req) is None + mw.process_request(req) + assert "Authorization" not in req.headers + + def test_auth_without_domain_raises(self): + with pytest.raises(ValueError, match="HTTPAUTH_DOMAIN"): + make_mw(user="foo", passwd="bar") + + def test_auth_all_domains(self): + mw = make_mw(user="foo", passwd="bar", domain=None) + req = Request("http://example.com/") + mw.process_request(req) + assert req.headers["Authorization"] == basic_auth_header("foo", "bar") + + def test_auth_domain_match(self): + mw = make_mw(user="foo", passwd="bar", domain="example.com") + req = Request("http://example.com/") + mw.process_request(req) assert req.headers["Authorization"] == basic_auth_header("foo", "bar") def test_auth_subdomain(self): - req = Request("http://foo.example.com/") - assert self.mw.process_request(req) is None + mw = make_mw(user="foo", passwd="bar", domain="example.com") + req = Request("http://sub.example.com/") + mw.process_request(req) assert req.headers["Authorization"] == basic_auth_header("foo", "bar") + def test_no_auth_wrong_domain(self): + mw = make_mw(user="foo", passwd="bar", domain="example.com") + req = Request("http://other.com/") + mw.process_request(req) + assert "Authorization" not in req.headers + def test_auth_already_set(self): + mw = make_mw(user="foo", passwd="bar", domain="example.com") req = Request("http://example.com/", headers={"Authorization": "Digest 123"}) - assert self.mw.process_request(req) is None + mw.process_request(req) assert req.headers["Authorization"] == b"Digest 123" -class TestHttpAuthAnyMiddleware: - def setup_method(self): - self.mw = HttpAuthMiddleware() - spider = AnyDomainSpider("foo") - self.mw.spider_opened(spider) +# --- Per-request meta tests --- - def teardown_method(self): - del self.mw - def test_auth(self): - req = Request("http://example.com/") - assert self.mw.process_request(req) is None - assert req.headers["Authorization"] == basic_auth_header("foo", "bar") +class TestHttpAuthMiddlewareMeta: + def test_meta_auth_no_domain(self): + mw = make_mw() + req = Request("http://example.com/", meta={"http_user": "u", "http_pass": "p"}) + mw.process_request(req) + assert req.headers["Authorization"] == basic_auth_header("u", "p") - def test_auth_already_set(self): - req = Request("http://example.com/", headers={"Authorization": "Digest 123"}) - assert self.mw.process_request(req) is None + def test_meta_auth_domain_match(self): + mw = make_mw() + req = Request( + "http://example.com/", + meta={ + "http_user": "u", + "http_pass": "p", + "http_auth_domain": "example.com", + }, + ) + mw.process_request(req) + assert req.headers["Authorization"] == basic_auth_header("u", "p") + + def test_meta_auth_domain_no_match(self): + mw = make_mw() + req = Request( + "http://other.com/", + meta={ + "http_user": "u", + "http_pass": "p", + "http_auth_domain": "example.com", + }, + ) + mw.process_request(req) + assert "Authorization" not in req.headers + + def test_meta_overrides_middleware(self): + mw = make_mw(user="mw_user", passwd="mw_pass", domain="example.com") + req = Request( + "http://example.com/", + meta={"http_user": "meta_user", "http_pass": "meta_pass"}, + ) + mw.process_request(req) + assert req.headers["Authorization"] == basic_auth_header( + "meta_user", "meta_pass" + ) + + def test_meta_already_set(self): + mw = make_mw() + req = Request( + "http://example.com/", + headers={"Authorization": "Digest 123"}, + meta={"http_user": "u", "http_pass": "p"}, + ) + mw.process_request(req) assert req.headers["Authorization"] == b"Digest 123" From 7afc875081c5a3a26fdc8d717818d84c3bd789fd Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 12 Jun 2026 12:32:38 +0200 Subject: [PATCH 08/11] Add coverage for Item.__delitem__() (#7610) --- tests/test_item.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_item.py b/tests/test_item.py index 4eb37a344..34b054e12 100644 --- a/tests/test_item.py +++ b/tests/test_item.py @@ -48,6 +48,18 @@ class TestItem: with pytest.raises(KeyError): i["field"] + def test_delitem(self): + class TestItem(Item): + name = Field() + + i = TestItem(name="John") + del i["name"] + with pytest.raises(KeyError): + i["name"] + + with pytest.raises(KeyError): + del i["name"] + def test_repr(self): class TestItem(Item): name = Field() From b08ed1cf05b983030e9b742a1ffe2891cb18d321 Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 12 Jun 2026 13:13:17 +0200 Subject: [PATCH 09/11] Add coverage for DummySpiderLoader.find_by_request() (#7608) --- tests/test_spiderloader/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_spiderloader/__init__.py b/tests/test_spiderloader/__init__.py index 245507c0b..de27ad519 100644 --- a/tests/test_spiderloader/__init__.py +++ b/tests/test_spiderloader/__init__.py @@ -252,3 +252,4 @@ def test_dummy_spider_loader(spider_loader_env): assert not spider_loader.list() with pytest.raises(KeyError): spider_loader.load("spider1") + assert not spider_loader.find_by_request(Request("http://example.com")) From 983e6c11826f376272edd9a8819345fd43ffdca7 Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 12 Jun 2026 14:56:15 +0200 Subject: [PATCH 10/11] Link.__eq__: return NotImplemented instead of raising NotImplementedError (#7611) --- scrapy/link.py | 2 +- tests/test_link.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/scrapy/link.py b/scrapy/link.py index 9c272ab2f..046630403 100644 --- a/scrapy/link.py +++ b/scrapy/link.py @@ -39,7 +39,7 @@ class Link: def __eq__(self, other: object) -> bool: if not isinstance(other, Link): - raise NotImplementedError + return NotImplemented return ( self.url == other.url and self.text == other.text diff --git a/tests/test_link.py b/tests/test_link.py index c49e5c090..0eeffe12b 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -55,3 +55,7 @@ class TestLink: def test_bytes_url(self): with pytest.raises(TypeError): Link(b"http://www.example.com/\xc2\xa3") + + def test_eq_non_link(self): + url = "http://example.com" + assert Link(url) != url From af30cfea120a0b0853984bedc350e8dc114bba25 Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 12 Jun 2026 16:15:36 +0200 Subject: [PATCH 11/11] Complete coverage for addons.py (#7612) --- tests/test_addons.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_addons.py b/tests/test_addons.py index 3ad8cf7ff..db0fb2f31 100644 --- a/tests/test_addons.py +++ b/tests/test_addons.py @@ -71,6 +71,37 @@ class TestAddonManager: manager = crawler.addons assert not manager.addons + def test_notconfigured_with_args(self): + class NotConfiguredAddon: + def update_settings(self, settings): + raise NotConfigured("addon disabled reason") + + settings_dict = { + "ADDONS": {NotConfiguredAddon: 0}, + } + with patch("scrapy.addons.logger") as logger_mock: + crawler = get_crawler(settings_dict=settings_dict) + assert not crawler.addons.addons + logger_mock.warning.assert_called_once_with( + "Disabled %(clspath)s: %(eargs)s", + {"clspath": NotConfiguredAddon, "eargs": "addon disabled reason"}, + extra={"crawler": crawler}, + ) + + def test_no_update_settings(self): + class PreCrawlerOnlyAddon: + @classmethod + def update_pre_crawler_settings(cls, settings): + settings.set("PRE_CRAWLER_KEY", "value", priority="addon") + + settings_dict = { + "ADDONS": {PreCrawlerOnlyAddon: 0}, + } + crawler = get_crawler(settings_dict=settings_dict) + manager = crawler.addons + assert len(manager.addons) == 1 + assert isinstance(manager.addons[0], PreCrawlerOnlyAddon) + def test_load_settings_order(self): # Get three addons with different settings addonlist = []