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
This commit is contained in:
Adrian 2026-04-22 16:00:39 +02:00 committed by Andrey Rakhmatullin
parent 47e25fbbb5
commit da6dfae750
8 changed files with 106 additions and 18 deletions

View File

@ -73,7 +73,9 @@ class Command(ScrapyCommand):
def run(self, args: list[str], opts: argparse.Namespace) -> None: def run(self, args: list[str], opts: argparse.Namespace) -> None:
# load contracts # load contracts
assert self.settings is not None 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) conman = ContractsManager(load_object(c) for c in contracts)
runner = TextTestRunner(verbosity=2 if opts.verbose else 1) runner = TextTestRunner(verbosity=2 if opts.verbose else 1)
result = TextTestResult(runner.stream, runner.descriptions, runner.verbosity) result = TextTestResult(runner.stream, runner.descriptions, runner.verbosity)

View File

@ -36,7 +36,9 @@ class DownloaderMiddlewareManager(MiddlewareManager):
@classmethod @classmethod
def _get_mwlist_from_settings(cls, settings: BaseSettings) -> list[Any]: 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: def _add_middleware(self, mw: Any) -> None:
if hasattr(mw, "process_request"): if hasattr(mw, "process_request"):

View File

@ -56,7 +56,9 @@ class SpiderMiddlewareManager(MiddlewareManager):
@classmethod @classmethod
def _get_mwlist_from_settings(cls, settings: BaseSettings) -> list[Any]: 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: def __init__(self, *middlewares: Any, crawler: Crawler | None = None) -> None:
self._check_deprecated_process_start_requests_use(middlewares) self._check_deprecated_process_start_requests_use(middlewares)

View File

@ -20,4 +20,6 @@ class ExtensionManager(MiddlewareManager):
@classmethod @classmethod
def _get_mwlist_from_settings(cls, settings: Settings) -> list[Any]: 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")
)

View File

@ -33,7 +33,9 @@ class ItemPipelineManager(MiddlewareManager):
@classmethod @classmethod
def _get_mwlist_from_settings(cls, settings: Settings) -> list[Any]: 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: def _add_middleware(self, pipe: Any) -> None:
if hasattr(pipe, "open_spider"): if hasattr(pipe, "open_spider"):

View File

@ -323,12 +323,34 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
return copy.deepcopy(value) return copy.deepcopy(value)
def getwithbase(self, name: _SettingsKey) -> BaseSettings: 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. counterpart.
Use
:meth:`~scrapy.settings.BaseSettings.get_component_priority_dict_with_base`
instead if the setting is a :ref:`component priority dictionary
<component-priority-dictionaries>`.
:param name: name of the dictionary-like setting :param name: name of the dictionary-like setting
:type name: str :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): if not isinstance(name, str):
raise ValueError(f"Base setting key must be a string, got {name}") raise ValueError(f"Base setting key must be a string, got {name}")
@ -345,10 +367,10 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
f"be kept." f"be kept."
) )
def normalize_key(key: Any) -> str: def normalize_key(key: Any) -> Any:
try: try:
loaded_key = load_object(key) loaded_key = load_object(key)
except (AttributeError, TypeError, ValueError): except (NameError, TypeError, ValueError):
loaded_key = key loaded_key = key
else: else:
import_path = global_object_name(loaded_key) import_path = global_object_name(loaded_key)

View File

@ -186,7 +186,9 @@ class CheckSpider(scrapy.Spider):
output = StringIO() output = StringIO()
sys.stdout = output sys.stdout = output
cmd = Command() 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() cm_cls_mock.return_value = cm_mock = Mock()
spider_loader_mock = Mock() spider_loader_mock = Mock()
cmd.crawler_process = Mock(spider_loader=spider_loader_mock) cmd.crawler_process = Mock(spider_loader=spider_loader_mock)
@ -211,7 +213,9 @@ class CheckSpider(scrapy.Spider):
self, cm_cls_mock self, cm_cls_mock
) -> None: ) -> None:
cmd = Command() 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() cm_cls_mock.return_value = cm_mock = Mock()
spider_loader_mock = Mock() spider_loader_mock = Mock()
cmd.crawler_process = Mock(spider_loader=spider_loader_mock) cmd.crawler_process = Mock(spider_loader=spider_loader_mock)

View File

@ -410,7 +410,45 @@ class TestBaseSettings:
assert frozencopy.frozen assert frozencopy.frozen
assert frozencopy is not self.settings 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() settings = BaseSettings()
setting_names = set() setting_names = set()
for k, v in scrapy_default_settings.__dict__.items(): 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 load_object(import_path): None for import_path in v
} }
for setting_name in setting_names: 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) 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() settings = BaseSettings()
setting_names = set() setting_names = set()
value = 0 value = 0
@ -446,7 +484,10 @@ class TestBaseSettings:
load_object(import_path): value for import_path in v load_object(import_path): value for import_path in v
} }
for setting_name in setting_names: 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): def test_getwithbase_for_non_component_priority_dicts(self):
settings = BaseSettings() settings = BaseSettings()
@ -465,7 +506,9 @@ class TestBaseSettings:
assert isinstance(value, BaseSettings) assert isinstance(value, BaseSettings)
assert dict(value) == expected 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 = BaseSettings()
settings["FOO"] = BaseSettings( settings["FOO"] = BaseSettings(
{ {
@ -474,20 +517,22 @@ class TestBaseSettings:
} }
) )
with caplog.at_level(logging.WARNING): with caplog.at_level(logging.WARNING):
value = settings.getwithbase("FOO") value = settings.get_component_priority_dict_with_base("FOO")
assert isinstance(value, BaseSettings) assert isinstance(value, BaseSettings)
assert dict(value) == {"scrapy.http.Request": 2} assert dict(value) == {"scrapy.http.Request": 2}
assert caplog.records, "Expected a warning to be logged" assert caplog.records, "Expected a warning to be logged"
msg = caplog.records[0].message msg = caplog.records[0].message
assert "scrapy.http.request.Request" in msg 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 = BaseSettings()
settings["FOO"] = BaseSettings( settings["FOO"] = BaseSettings(
{Component1: 1, "tests.test_settings.Component1": 2} {Component1: 1, "tests.test_settings.Component1": 2}
) )
with caplog.at_level(logging.WARNING): with caplog.at_level(logging.WARNING):
value = settings.getwithbase("FOO") value = settings.get_component_priority_dict_with_base("FOO")
assert isinstance(value, BaseSettings) assert isinstance(value, BaseSettings)
assert dict(value) == {"tests.test_settings.Component1": 2} assert dict(value) == {"tests.test_settings.Component1": 2}
assert caplog.records, "Expected a warning to be logged" assert caplog.records, "Expected a warning to be logged"
@ -501,6 +546,13 @@ class TestBaseSettings:
): ):
settings.getwithbase(123) 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: class TestSettings:
def setup_method(self): def setup_method(self):