From da6dfae7506c0dde5f6744c4a88dcb47469dd7c5 Mon Sep 17 00:00:00 2001 From: Adrian Date: Wed, 22 Apr 2026 16:00:39 +0200 Subject: [PATCH 1/5] Restore 2.14 getwithbase, add a new method for class key deduplication (#7449) * Restore 2.14 getwithbase, add a new method for class key deduplication * Use the original getwithbase code, not some equivalent * Fix mocking * Test key exceptions --- scrapy/commands/check.py | 4 +- scrapy/core/downloader/middleware.py | 4 +- scrapy/core/spidermw.py | 4 +- scrapy/extension.py | 4 +- scrapy/pipelines/__init__.py | 4 +- scrapy/settings/__init__.py | 28 ++++++++++-- tests/test_command_check.py | 8 +++- tests/test_settings/__init__.py | 68 ++++++++++++++++++++++++---- 8 files changed, 106 insertions(+), 18 deletions(-) diff --git a/scrapy/commands/check.py b/scrapy/commands/check.py index 229e8a487..2113c19d2 100644 --- a/scrapy/commands/check.py +++ b/scrapy/commands/check.py @@ -73,7 +73,9 @@ class Command(ScrapyCommand): def run(self, args: list[str], opts: argparse.Namespace) -> None: # load contracts assert self.settings is not None - contracts = build_component_list(self.settings.getwithbase("SPIDER_CONTRACTS")) + contracts = build_component_list( + self.settings.get_component_priority_dict_with_base("SPIDER_CONTRACTS") + ) conman = ContractsManager(load_object(c) for c in contracts) runner = TextTestRunner(verbosity=2 if opts.verbose else 1) result = TextTestResult(runner.stream, runner.descriptions, runner.verbosity) diff --git a/scrapy/core/downloader/middleware.py b/scrapy/core/downloader/middleware.py index 431579856..d08c6f8e3 100644 --- a/scrapy/core/downloader/middleware.py +++ b/scrapy/core/downloader/middleware.py @@ -36,7 +36,9 @@ class DownloaderMiddlewareManager(MiddlewareManager): @classmethod def _get_mwlist_from_settings(cls, settings: BaseSettings) -> list[Any]: - return build_component_list(settings.getwithbase("DOWNLOADER_MIDDLEWARES")) + return build_component_list( + settings.get_component_priority_dict_with_base("DOWNLOADER_MIDDLEWARES") + ) def _add_middleware(self, mw: Any) -> None: if hasattr(mw, "process_request"): diff --git a/scrapy/core/spidermw.py b/scrapy/core/spidermw.py index 39342ebc0..67342099d 100644 --- a/scrapy/core/spidermw.py +++ b/scrapy/core/spidermw.py @@ -56,7 +56,9 @@ class SpiderMiddlewareManager(MiddlewareManager): @classmethod def _get_mwlist_from_settings(cls, settings: BaseSettings) -> list[Any]: - return build_component_list(settings.getwithbase("SPIDER_MIDDLEWARES")) + return build_component_list( + settings.get_component_priority_dict_with_base("SPIDER_MIDDLEWARES") + ) def __init__(self, *middlewares: Any, crawler: Crawler | None = None) -> None: self._check_deprecated_process_start_requests_use(middlewares) diff --git a/scrapy/extension.py b/scrapy/extension.py index 9f978fa32..05cce326b 100644 --- a/scrapy/extension.py +++ b/scrapy/extension.py @@ -20,4 +20,6 @@ class ExtensionManager(MiddlewareManager): @classmethod def _get_mwlist_from_settings(cls, settings: Settings) -> list[Any]: - return build_component_list(settings.getwithbase("EXTENSIONS")) + return build_component_list( + settings.get_component_priority_dict_with_base("EXTENSIONS") + ) diff --git a/scrapy/pipelines/__init__.py b/scrapy/pipelines/__init__.py index f58864471..18473c534 100644 --- a/scrapy/pipelines/__init__.py +++ b/scrapy/pipelines/__init__.py @@ -33,7 +33,9 @@ class ItemPipelineManager(MiddlewareManager): @classmethod def _get_mwlist_from_settings(cls, settings: Settings) -> list[Any]: - return build_component_list(settings.getwithbase("ITEM_PIPELINES")) + return build_component_list( + settings.get_component_priority_dict_with_base("ITEM_PIPELINES") + ) def _add_middleware(self, pipe: Any) -> None: if hasattr(pipe, "open_spider"): diff --git a/scrapy/settings/__init__.py b/scrapy/settings/__init__.py index 456e90e77..2a1b1932c 100644 --- a/scrapy/settings/__init__.py +++ b/scrapy/settings/__init__.py @@ -323,12 +323,34 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): return copy.deepcopy(value) def getwithbase(self, name: _SettingsKey) -> BaseSettings: - """Get a composition of a dictionary-like setting and its `_BASE` + """Get a composition of a dictionary-like setting and its ``_BASE`` counterpart. + Use + :meth:`~scrapy.settings.BaseSettings.get_component_priority_dict_with_base` + instead if the setting is a :ref:`component priority dictionary + `. + :param name: name of the dictionary-like setting :type name: str """ + if not isinstance(name, str): + raise ValueError(f"Base setting key must be a string, got {name}") + compbs = BaseSettings() + compbs.update(self[name + "_BASE"]) + compbs.update(self[name]) + return compbs + + def get_component_priority_dict_with_base(self, name: _SettingsKey) -> BaseSettings: + """Get a composition of a component priority dictionary setting and + its ``_BASE`` counterpart. + + Keys are resolved to their import path for deduplication and then + restored to their latest input representation. + + :param name: name of the component priority dictionary setting + :type name: str + """ if not isinstance(name, str): raise ValueError(f"Base setting key must be a string, got {name}") @@ -345,10 +367,10 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]): f"be kept." ) - def normalize_key(key: Any) -> str: + def normalize_key(key: Any) -> Any: try: loaded_key = load_object(key) - except (AttributeError, TypeError, ValueError): + except (NameError, TypeError, ValueError): loaded_key = key else: import_path = global_object_name(loaded_key) diff --git a/tests/test_command_check.py b/tests/test_command_check.py index 84486e717..fd1ff612c 100644 --- a/tests/test_command_check.py +++ b/tests/test_command_check.py @@ -186,7 +186,9 @@ class CheckSpider(scrapy.Spider): output = StringIO() sys.stdout = output cmd = Command() - cmd.settings = Mock(getwithbase=Mock(return_value={})) + cmd.settings = Mock( + get_component_priority_dict_with_base=Mock(return_value={}), + ) cm_cls_mock.return_value = cm_mock = Mock() spider_loader_mock = Mock() cmd.crawler_process = Mock(spider_loader=spider_loader_mock) @@ -211,7 +213,9 @@ class CheckSpider(scrapy.Spider): self, cm_cls_mock ) -> None: cmd = Command() - cmd.settings = Mock(getwithbase=Mock(return_value={})) + cmd.settings = Mock( + get_component_priority_dict_with_base=Mock(return_value={}), + ) cm_cls_mock.return_value = cm_mock = Mock() spider_loader_mock = Mock() cmd.crawler_process = Mock(spider_loader=spider_loader_mock) diff --git a/tests/test_settings/__init__.py b/tests/test_settings/__init__.py index 4436f03ba..1d4c3d487 100644 --- a/tests/test_settings/__init__.py +++ b/tests/test_settings/__init__.py @@ -410,7 +410,45 @@ class TestBaseSettings: assert frozencopy.frozen assert frozencopy is not self.settings - def test_getwithbase_override_none_by_type(self): + def test_getwithbase_for_dotted_keys(self): + settings = BaseSettings( + { + "FEED_EXPORTERS_BASE": BaseSettings({"json": "foo"}), + "FEED_EXPORTERS": BaseSettings({"csv.gz": "bar"}), + } + ) + value = settings.getwithbase("FEED_EXPORTERS") + assert isinstance(value, BaseSettings) + assert dict(value) == { + "json": "foo", + "csv.gz": "bar", + } + + @pytest.mark.parametrize( + ("key", "exception"), + [ + pytest.param(1, TypeError, id="type-error"), + pytest.param("foo", ValueError, id="value-error"), + pytest.param("csv.gz", NameError, id="name-error"), + ], + ) + def test_get_component_priority_dict_with_base_handles_load_object_exceptions( + self, key, exception + ): + with pytest.raises(exception): + load_object(key) + + settings = BaseSettings( + { + "FOO": BaseSettings({key: 1}), + } + ) + value = settings.get_component_priority_dict_with_base("FOO") + + assert isinstance(value, BaseSettings) + assert dict(value) == {key: 1} + + def test_get_component_priority_dict_with_base_override_none_by_type(self): settings = BaseSettings() setting_names = set() for k, v in scrapy_default_settings.__dict__.items(): @@ -426,10 +464,10 @@ class TestBaseSettings: load_object(import_path): None for import_path in v } for setting_name in setting_names: - value = settings.getwithbase(setting_name) + value = settings.get_component_priority_dict_with_base(setting_name) assert not dict(value) - def test_getwithbase_override_value_by_type(self): + def test_get_component_priority_dict_with_base_override_value_by_type(self): settings = BaseSettings() setting_names = set() value = 0 @@ -446,7 +484,10 @@ class TestBaseSettings: load_object(import_path): value for import_path in v } for setting_name in setting_names: - assert settings.getwithbase(setting_name) == settings[setting_name] + assert ( + settings.get_component_priority_dict_with_base(setting_name) + == settings[setting_name] + ) def test_getwithbase_for_non_component_priority_dicts(self): settings = BaseSettings() @@ -465,7 +506,9 @@ class TestBaseSettings: assert isinstance(value, BaseSettings) assert dict(value) == expected - def test_getwithbase_warns_on_duplicate_import_paths(self, caplog): + def test_get_component_priority_dict_with_base_warns_on_duplicate_import_paths( + self, caplog + ): settings = BaseSettings() settings["FOO"] = BaseSettings( { @@ -474,20 +517,22 @@ class TestBaseSettings: } ) with caplog.at_level(logging.WARNING): - value = settings.getwithbase("FOO") + value = settings.get_component_priority_dict_with_base("FOO") assert isinstance(value, BaseSettings) assert dict(value) == {"scrapy.http.Request": 2} assert caplog.records, "Expected a warning to be logged" msg = caplog.records[0].message assert "scrapy.http.request.Request" in msg - def test_getwithbase_warns_on_duplicate_mixed_type_and_path(self, caplog): + def test_get_component_priority_dict_with_base_warns_on_duplicate_mixed_type_and_path( + self, caplog + ): settings = BaseSettings() settings["FOO"] = BaseSettings( {Component1: 1, "tests.test_settings.Component1": 2} ) with caplog.at_level(logging.WARNING): - value = settings.getwithbase("FOO") + value = settings.get_component_priority_dict_with_base("FOO") assert isinstance(value, BaseSettings) assert dict(value) == {"tests.test_settings.Component1": 2} assert caplog.records, "Expected a warning to be logged" @@ -501,6 +546,13 @@ class TestBaseSettings: ): settings.getwithbase(123) + def test_get_component_priority_dict_with_base_invalid_setting_name(self): + settings = BaseSettings() + with pytest.raises( + ValueError, match="Base setting key must be a string, got 123" + ): + settings.get_component_priority_dict_with_base(123) + class TestSettings: def setup_method(self): From b7cd42da39834267d5b854a796cf6023021e1d95 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Wed, 22 Apr 2026 19:01:01 +0500 Subject: [PATCH 2/5] Revert sharing of the SSL context in _ScrapyClientContextFactory. (#7450) --- scrapy/core/downloader/contextfactory.py | 32 ++++++++++++-------- scrapy/utils/_deps_compat.py | 4 ++- tests/test_core_downloader.py | 38 +++++++++++++++++++++++- 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/scrapy/core/downloader/contextfactory.py b/scrapy/core/downloader/contextfactory.py index ef0a46a8e..19fc77959 100644 --- a/scrapy/core/downloader/contextfactory.py +++ b/scrapy/core/downloader/contextfactory.py @@ -78,13 +78,6 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): self.tls_ciphers = AcceptableCiphers.fromOpenSSLCipherString(tls_ciphers) else: self.tls_ciphers = DEFAULT_CIPHERS - with _filter_method_warning(): - self._certificate_options = CertificateOptions( - method=self._ssl_method, - fixBrokenPeers=True, - acceptableCiphers=self.tls_ciphers, - ) - self._ctx = self._get_context() self._verify_certificates = verify_certificates @classmethod @@ -109,23 +102,38 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): **kwargs, ) + # should be removed together with ScrapyClientContextFactory def getCertificateOptions(self) -> CertificateOptions: # pragma: no cover - return self._certificate_options + return self._get_cert_options() + + def _get_cert_options(self) -> CertificateOptions: + with _filter_method_warning(): + return CertificateOptions( + method=self._ssl_method, + fixBrokenPeers=True, + acceptableCiphers=self.tls_ciphers, + ) # kept for old-style HTTP/1.0 downloader context twisted calls, # e.g. connectSSL() + # should be removed together with ScrapyClientContextFactory def getContext(self, hostname: Any = None, port: Any = None) -> SSL.Context: - return self._ctx + return self._get_context() def _get_context(self) -> SSL.Context: - ctx = self._certificate_options.getContext() + cert_options = self._get_cert_options() + ctx = cert_options.getContext() ctx.set_options(0x4) # OP_LEGACY_SERVER_CONNECT return ctx def creatorForNetloc(self, hostname: bytes, port: int) -> ClientTLSOptions: if not self._verify_certificates: - return _ScrapyClientTLSOptions(hostname.decode("ascii"), self._ctx) # type: ignore[no-untyped-call] - # Note that this doesn't use self._ctx + # _ScrapyClientTLSOptions is needed to skip verification errors + return _ScrapyClientTLSOptions( + hostname.decode("ascii"), self._get_context() + ) # type: ignore[no-untyped-call] + # Otherwise use the normal Twisted function. + # Note that this doesn't use self._get_context(). with _filter_method_warning(): return optionsForClientTLS( hostname=hostname.decode("ascii"), diff --git a/scrapy/utils/_deps_compat.py b/scrapy/utils/_deps_compat.py index 92399b31f..2ca6f657c 100644 --- a/scrapy/utils/_deps_compat.py +++ b/scrapy/utils/_deps_compat.py @@ -6,5 +6,7 @@ from twisted.python.versions import Version as TxVersion TWISTED_FAILURE_HAS_STACK = TWISTED_VERSION < TxVersion("twisted", 24, 10, 0) PYOPENSSL_VERSION = Version(PYOPENSSL_VERSION_STRING) -# SSL.Context.use_certificate wants an X509 object, SSL.Context.use_privatekey wants a PKey object +# SSL.Context.use_certificate() wants an X509 object, SSL.Context.use_privatekey() wants a PKey object PYOPENSSL_WANTS_X509_PKEY = PYOPENSSL_VERSION < Version("24.3.0") +# 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") diff --git a/tests/test_core_downloader.py b/tests/test_core_downloader.py index cddc53b16..f9c5abf8a 100644 --- a/tests/test_core_downloader.py +++ b/tests/test_core_downloader.py @@ -17,6 +17,7 @@ from scrapy.core.downloader.contextfactory import ( ) from scrapy.core.downloader.handlers.http11 import _RequestBodyProducer from scrapy.exceptions import ScrapyDeprecationWarning +from scrapy.utils._deps_compat import PYOPENSSL_SET_CIPHER_LIST_TMP_CONN from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.misc import build_from_crawler from scrapy.utils.python import to_bytes @@ -98,7 +99,7 @@ class TestContextFactoryBase: class TestContextFactory(TestContextFactoryBase): @coroutine_test - async def testPayload(self, server_url: str) -> None: + async def test_payload(self, server_url: str) -> None: s = "0123456789" * 10 crawler = get_crawler() client_context_factory = _load_context_factory_from_settings(crawler) @@ -107,6 +108,41 @@ class TestContextFactory(TestContextFactoryBase): ) assert body == to_bytes(s) + def test_no_context_sharing(self) -> None: + """Every call to creatorForNetloc() should give a fresh context.""" + crawler = get_crawler() + client_context_factory: _ScrapyClientContextFactory = ( + _load_context_factory_from_settings(crawler) + ) + creator1 = client_context_factory.creatorForNetloc(b"website1.tld", 443) + assert creator1._hostnameBytes == b"website1.tld" + creator2 = client_context_factory.creatorForNetloc(b"website2.tld", 443) + assert creator2._hostnameBytes == b"website2.tld" + assert creator1._ctx is not creator2._ctx + + @pytest.mark.skipif( + PYOPENSSL_SET_CIPHER_LIST_TMP_CONN, + reason="Fails or doesn't make sense on this pyOpenSSL version", + ) + def test_no_immutable_ctx_warning(self) -> None: + """There should be no pyOpenSSL context modification warning. + + pyOpenSSL < 25.1.0 doesn't produce this warning, and on 25.1.0 it's + always produced due to + https://github.com/scrapy/scrapy/issues/6859#issuecomment-4294917851. + """ + crawler = get_crawler() + client_context_factory: _ScrapyClientContextFactory = ( + _load_context_factory_from_settings(crawler) + ) + with warnings.catch_warnings(): + warnings.filterwarnings( + "error", + category=DeprecationWarning, + message="Attempting to mutate a Context after a Connection was created", + ) + client_context_factory.creatorForNetloc(b"website.tld", 443) + class TestContextFactoryTLSMethod(TestContextFactoryBase): async def _assert_factory_works( From 3561748280f3acbabca03818f131c7de71f0269a Mon Sep 17 00:00:00 2001 From: Adrian Date: Thu, 23 Apr 2026 10:03:09 +0200 Subject: [PATCH 3/5] =?UTF-8?q?sphinx-scrapy:=200.7.1=20=E2=86=92=200.8.3?= =?UTF-8?q?=20(#7454)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 2 +- docs/requirements.in | 2 +- docs/requirements.txt | 11 ++++++++--- tox.ini | 2 +- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index de5ad0a39..13709efde 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,6 +27,6 @@ repos: hooks: - id: sphinx-lint - repo: https://github.com/scrapy/sphinx-scrapy - rev: 0.7.1 + rev: 0.8.3 hooks: - id: sphinx-scrapy diff --git a/docs/requirements.in b/docs/requirements.in index 3b1cbb226..13b9dfb5d 100644 --- a/docs/requirements.in +++ b/docs/requirements.in @@ -5,4 +5,4 @@ sphinx sphinx-notfound-page sphinx-rtd-theme sphinx-rtd-dark-mode -sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.7.1 +sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.3 diff --git a/docs/requirements.txt b/docs/requirements.txt index 6da4a52c9..4d9eb5454 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile requirements.in -o requirements.txt +# uv pip compile -p 3.13 requirements.in -o requirements.txt alabaster==1.0.0 # via sphinx annotated-types==0.7.0 @@ -130,6 +130,7 @@ sphinx==9.1.0 # via # -r requirements.in # sphinx-copybutton + # sphinx-last-updated-by-git # sphinx-llms-txt # sphinx-markdown-builder # sphinx-notfound-page @@ -138,9 +139,11 @@ sphinx==9.1.0 # sphinxcontrib-jquery sphinx-copybutton==0.5.2 # via sphinx-scrapy +sphinx-last-updated-by-git==0.3.8 + # via sphinx-sitemap sphinx-llms-txt @ git+https://github.com/zytedata/sphinx-llms-txt.git@5e8866cb0cc249aa2017ad9050b3b83a7ca16f69 # via sphinx-scrapy -sphinx-markdown-builder @ git+https://github.com/zytedata/sphinx-markdown-builder.git@ac9f8babfe622e4300099ab44b96d9d9228e742e +sphinx-markdown-builder @ git+https://github.com/zytedata/sphinx-markdown-builder.git@cfe4c0bfd7b4542f7e6b65a58cdf9ec765829940 # via sphinx-scrapy sphinx-notfound-page==1.1.0 # via -r requirements.in @@ -150,8 +153,10 @@ sphinx-rtd-theme==3.1.0 # via # -r requirements.in # sphinx-rtd-dark-mode -sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@2b5f6c7de64c8317cb771fdeb2e5020d1c9c9dcf +sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@f20366277f2598d0c8a60e55fe282aff2da40dcf # via -r requirements.in +sphinx-sitemap==2.9.0 + # via sphinx-scrapy sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 diff --git a/tox.ini b/tox.ini index d0bcb4f81..5060a043f 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,7 @@ [tox] requires = - sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.7.1 + sphinx-scrapy[tox] @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.3 envlist = pre-commit,pylint,typing,py,docs minversion = 1.7.0 From 416a454dc56a33f4d87ef288a1536f1356da4202 Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 23 Apr 2026 19:51:00 +0500 Subject: [PATCH 4/5] Release notes for 2.15.1. (#7456) --- docs/news.rst | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/news.rst b/docs/news.rst index d1276b82a..fa2f104b7 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -3,6 +3,31 @@ Release notes ============= +.. _release-2.15.1: + +Scrapy 2.15.1 (unreleased) +-------------------------- + +Bug fixes +~~~~~~~~~ + +- Sharing of the SSL context between multiple connections, introduced in + Scrapy 2.15.0, is reverted as it caused problems and wasn't actually + needed. + (:issue:`7445`, :issue:`7450`) + +- Fixed :meth:`scrapy.settings.BaseSettings.getwithbase` failing on keys with + dots that aren't import names. It now works the way it worked before Scrapy + 2.15.0, without trying to match class objects and import path. A separate + method, + :func:`~scrapy.settings.BaseSettings.get_component_priority_dict_with_base`, + was added that does that, and it is now used for :ref:`component priority + dictionaries `. + (:issue:`7426`, :issue:`7449`) + +- Documentation rendering improvements. + (:issue:`7452`, :issue:`7454`) + .. _release-2.15.0: Scrapy 2.15.0 (2026-04-09) From f3c5a6e75f53fcc4e885359d64baf9fe84f85fac Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Thu, 23 Apr 2026 19:57:44 +0500 Subject: [PATCH 5/5] =?UTF-8?q?Bump=20version:=202.15.0=20=E2=86=92=202.15?= =?UTF-8?q?.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/news.rst | 2 +- pyproject.toml | 2 +- scrapy/VERSION | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index fa2f104b7..aef567a2a 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -5,7 +5,7 @@ Release notes .. _release-2.15.1: -Scrapy 2.15.1 (unreleased) +Scrapy 2.15.1 (2026-04-23) -------------------------- Bug fixes diff --git a/pyproject.toml b/pyproject.toml index abafa3037..42d00d526 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -154,7 +154,7 @@ module = [ ignore_missing_imports = true [tool.bumpversion] -current_version = "2.15.0" +current_version = "2.15.1" commit = true tag = true tag_name = "{new_version}" diff --git a/scrapy/VERSION b/scrapy/VERSION index 68e69e405..3b1fc7950 100644 --- a/scrapy/VERSION +++ b/scrapy/VERSION @@ -1 +1 @@ -2.15.0 +2.15.1