Repository: scrapy/scrapy
Pull request #6614 (2025-01-14 15:52:58)

Extend BaseSettings with utils for add-ons

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/scrapy/scrapy/pull/6614?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scrapy) Report
Attention: Patch coverage is `13.95349%` with `37 lines` in your changes missing coverage. Please review.
> Project coverage is 33.06%. Comparing base [(`402500b`)](https://app.codecov.io/gh/scrapy/scrapy/commit/402500b164efc01257679247d3dd1628a5f90f5e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scrapy) to head [(`ab631c0`)](https://app.codecov.io/gh/scrapy/scrapy/commit/ab631c03c64a495e4c939bc719a2e248d3b30b51?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scrapy).
> Report is 2 commits behind head on master.

| [Files with missing lines](https://app.codecov.io/gh/scrapy/scrapy/pull/6614?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scrapy) | Patch % | Lines |
|---|---|---|
| [scrapy/settings/\_\_init\_\_.py](https://app.codecov.io/gh/scrapy/scrapy/pull/6614?src=pr&el=tree&filepath=scrapy%2Fsettings%2F__init__.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scrapy#diff-c2NyYXB5L3NldHRpbmdzL19faW5pdF9fLnB5) | 13.95% | [37 Missing :warning: ](https://app.codecov.io/gh/scrapy/scrapy/pull/6614?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scrapy) |

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##           master    #6614       +/-   ##
===========================================
- Coverage   91.33%   33.06%   -58.27%     
===========================================
  Files         162      162               
  Lines       12026    12070       +44     
  Branches     1544     1556       +12     
===========================================
- Hits        10984     3991     -6993     
- Misses        736     7952     +7216     
+ Partials      306      127      -179     
```

| [Files with missing lines](https://app.codecov.io/gh/scrapy/scrapy/pull/6614?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scrapy) | Coverage Δ | |
|---|---|---|
| [scrapy/settings/\_\_init\_\_.py](https://app.codecov.io/gh/scrapy/scrapy/pull/6614?src=pr&el=tree&filepath=scrapy%2Fsettings%2F__init__.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scrapy#diff-c2NyYXB5L3NldHRpbmdzL19faW5pdF9fLnB5) | `52.32% <13.95%> (-45.11%)` | :arrow_down: |

... and [147 files with indirect coverage changes](https://app.codecov.io/gh/scrapy/scrapy/pull/6614/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scrapy)

</details>
Gallaecio: I‘ll rename `dictionary` to `dict` in method names, in line with the corresponding class name. Shortening that feels OK to me, unlike component → comp or priority → prio. 

Patch:
diff --git a/docs/topics/addons.rst b/docs/topics/addons.rst
index 46cf1edbd..8ec7b0295 100644
--- a/docs/topics/addons.rst
+++ b/docs/topics/addons.rst
@@ -76,15 +76,11 @@ The settings set by the add-on should use the ``addon`` priority (see
             settings.set("DNSCACHE_ENABLED", True, "addon")
 
 This allows users to override these settings in the project or spider
-configuration. This is not possible with settings that are mutable objects,
-such as the dict that is a value of :setting:`ITEM_PIPELINES`. In these cases
-you can provide an add-on-specific setting that governs whether the add-on will
-modify :setting:`ITEM_PIPELINES`::
+configuration.
 
-    class MyAddon:
-        def update_settings(self, settings):
-            if settings.getbool("MYADDON_ENABLE_PIPELINE"):
-                settings["ITEM_PIPELINES"]["path.to.mypipeline"] = 200
+When editing the value of a setting instead of overriding it entirely, it is
+usually best to leave its priority unchanged. For example, when editing a
+:ref:`component priority dictionary <component-priority-dictionaries>`.
 
 If the ``update_settings`` method raises
 :exc:`scrapy.exceptions.NotConfigured`, the add-on will be skipped. This makes
@@ -127,12 +123,28 @@ Add-on examples
 
 Set some basic configuration:
 
+.. skip: next
 .. code-block:: python
 
+    from myproject.pipelines import MyPipeline
+
+
     class MyAddon:
         def update_settings(self, settings):
-            settings["ITEM_PIPELINES"]["path.to.mypipeline"] = 200
             settings.set("DNSCACHE_ENABLED", True, "addon")
+            settings.remove_from_list("METAREFRESH_IGNORE_TAGS", "noscript")
+            settings.setdefault_in_component_priority_dict(
+                "ITEM_PIPELINES", MyPipeline, 200
+            )
+
+.. tip:: When editing a :ref:`component priority dictionary
+    <component-priority-dictionaries>` setting, like :setting:`ITEM_PIPELINES`,
+    consider using setting methods like
+    :meth:`~scrapy.settings.BaseSettings.replace_in_component_priority_dict`,
+    :meth:`~scrapy.settings.BaseSettings.set_in_component_priority_dict`
+    and
+    :meth:`~scrapy.settings.BaseSettings.setdefault_in_component_priority_dict`
+    to avoid mistakes.
 
 Check dependencies:
 
diff --git a/docs/topics/settings.rst b/docs/topics/settings.rst
index a53e0806d..7646aca4f 100644
--- a/docs/topics/settings.rst
+++ b/docs/topics/settings.rst
@@ -250,6 +250,57 @@ example, proper setting names for a fictional robots.txt extension would be
 ``ROBOTSTXT_ENABLED``, ``ROBOTSTXT_OBEY``, ``ROBOTSTXT_CACHEDIR``, etc.
 
 
+.. _component-priority-dictionaries:
+
+Component priority dictionaries
+===============================
+
+A **component priority dictionary** is a :class:`dict` where keys are
+:ref:`components <topics-components>` and values are component priorities. For
+example:
+
+.. skip: next
+.. code-block:: python
+
+    {
+        "path.to.ComponentA": None,
+        ComponentB: 100,
+    }
+
+A component can be specified either as a class object or through an import
+path.
+
+.. warning:: Component priority dictionaries are regular :class:`dict` objects.
+    Be careful not to define the same component more than once, e.g. with
+    different import path strings or defining both an import path and a
+    :class:`type` object.
+
+A priority can be an :class:`int` or :data:`None`.
+
+A component with priority 1 goes *before* a component with priority 2. What
+going before entails, however, depends on the corresponding setting. For
+example, in the :setting:`DOWNLOADER_MIDDLEWARES` setting, components have
+their
+:meth:`~scrapy.downloadermiddlewares.DownloaderMiddleware.process_request`
+method executed before that of later components, but have their
+:meth:`~scrapy.downloadermiddlewares.DownloaderMiddleware.process_response`
+method executed after that of later components.
+
+A component with priority :data:`None` is disabled.
+
+Some component priority dictionaries get merged with some built-in value. For
+example, :setting:`DOWNLOADER_MIDDLEWARES` is merged with
+:setting:`DOWNLOADER_MIDDLEWARES_BASE`. This is where :data:`None` comes in
+handy, allowing you to disable a component from the base setting in the regular
+setting:
+
+.. code-block:: python
+
+    DOWNLOADER_MIDDLEWARES = {
+        "scrapy.downloadermiddlewares.offsite.OffsiteMiddleware": None,
+    }
+
+
 Special settings
 ================
 
diff --git a/scrapy/settings/__init__.py b/scrapy/settings/__init__.py
index f31f824a8..cc4853c8f 100644
--- a/scrapy/settings/__init__.py
+++ b/scrapy/settings/__init__.py
@@ -8,6 +8,7 @@ from pprint import pformat
 from typing import TYPE_CHECKING, Any, Union, cast
 
 from scrapy.settings import default_settings
+from scrapy.utils.misc import load_object
 
 # The key types are restricted in BaseSettings._get_key() to ones supported by JSON,
 # see https://github.com/scrapy/scrapy/issues/5383.
@@ -111,6 +112,31 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
     def __contains__(self, name: Any) -> bool:
         return name in self.attributes
 
+    def add_to_list(self, name: _SettingsKeyT, item: Any) -> None:
+        """Append *item* to the :class:`list` setting with the specified *name*
+        if *item* is not already in that list.
+
+        This change is applied regardless of the priority of the *name*
+        setting. The setting priority is not affected by this change either.
+        """
+        value: list[str] = self.getlist(name)
+        if item not in value:
+            self.set(name, [*value, item], self.getpriority(name) or 0)
+
+    def remove_from_list(self, name: _SettingsKeyT, item: Any) -> None:
+        """Remove *item* from the :class:`list` setting with the specified
+        *name*.
+
+        If *item* is missing, raise :exc:`ValueError`.
+
+        This change is applied regardless of the priority of the *name*
+        setting. The setting priority is not affected by this change either.
+        """
+        value: list[str] = self.getlist(name)
+        if item not in value:
+            raise ValueError(f"{item!r} not found in the {name} setting ({value!r}).")
+        self.set(name, [v for v in value if v != item], self.getpriority(name) or 0)
+
     def get(self, name: _SettingsKeyT, default: Any = None) -> Any:
         """
         Get a setting value without affecting its original type.
@@ -181,8 +207,9 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
         self, name: _SettingsKeyT, default: list[Any] | None = None
     ) -> list[Any]:
         """
-        Get a setting value as a list. If the setting original type is a list, a
-        copy of it will be returned. If it's a string it will be split by ",".
+        Get a setting value as a list. If the setting original type is a list,
+        a copy of it will be returned. If it's a string it will be split by
+        ",". If it is an empty string, an empty list will be returned.
 
         For example, settings populated through environment variables set to
         ``'one,two'`` will return a list ['one', 'two'] when using this method.
@@ -194,6 +221,8 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
         :type default: object
         """
         value = self.get(name, default or [])
+        if not value:
+            return []
         if isinstance(value, str):
             value = value.split(",")
         return list(value)
@@ -299,6 +328,47 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
             return max(cast(int, self.getpriority(name)) for name in self)
         return get_settings_priority("default")
 
+    def replace_in_component_priority_dict(
+        self,
+        name: _SettingsKeyT,
+        old_cls: type,
+        new_cls: type,
+        priority: int | None = None,
+    ) -> None:
+        """Replace *old_cls* with *new_cls* in the *name* :ref:`component
+        priority dictionary <component-priority-dictionaries>`.
+
+        If *old_cls* is missing, or has :data:`None` as value, :exc:`KeyError`
+        is raised.
+
+        If *old_cls* was present as an import string, even more than once,
+        those keys are dropped and replaced by *new_cls*.
+
+        If *priority* is specified, that is the value assigned to *new_cls* in
+        the component priority dictionary. Otherwise, the value of *old_cls* is
+        used. If *old_cls* was present multiple times (possible with import
+        strings) with different values, the value assigned to *new_cls* is one
+        of them, with no guarantee about which one it is.
+
+        This change is applied regardless of the priority of the *name*
+        setting. The setting priority is not affected by this change either.
+        """
+        component_priority_dict = self.getdict(name)
+        old_priority = None
+        for cls_or_path in tuple(component_priority_dict):
+            if load_object(cls_or_path) != old_cls:
+                continue
+            if (old_priority := component_priority_dict.pop(cls_or_path)) is None:
+                break
+        if old_priority is None:
+            raise KeyError(
+                f"{old_cls} not found in the {name} setting ({component_priority_dict!r})."
+            )
+        component_priority_dict[new_cls] = (
+            old_priority if priority is None else priority
+        )
+        self.set(name, component_priority_dict, priority=self.getpriority(name) or 0)
+
     def __setitem__(self, name: _SettingsKeyT, value: Any) -> None:
         self.set(name, value)
 
@@ -332,6 +402,30 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
         else:
             self.attributes[name].set(value, priority)
 
+    def set_in_component_priority_dict(
+        self, name: _SettingsKeyT, cls: type, priority: int | None
+    ) -> None:
+        """Set the *cls* component in the *name* :ref:`component priority
+        dictionary <component-priority-dictionaries>` setting with *priority*.
+
+        If *cls* already exists, its value is updated.
+
+        If *cls* was present as an import string, even more than once, those
+        keys are dropped and replaced by *cls*.
+
+        This change is applied regardless of the priority of the *name*
+        setting. The setting priority is not affected by this change either.
+        """
+        component_priority_dict = self.getdict(name)
+        for cls_or_path in tuple(component_priority_dict):
+            if not isinstance(cls_or_path, str):
+                continue
+            _cls = load_object(cls_or_path)
+            if _cls == cls:
+                del component_priority_dict[cls_or_path]
+        component_priority_dict[cls] = priority
+        self.set(name, component_priority_dict, self.getpriority(name) or 0)
+
     def setdefault(
         self,
         name: _SettingsKeyT,
@@ -344,6 +438,24 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
 
         return self.attributes[name].value
 
+    def setdefault_in_component_priority_dict(
+        self, name: _SettingsKeyT, cls: type, priority: int | None
+    ) -> None:
+        """Set the *cls* component in the *name* :ref:`component priority
+        dictionary <component-priority-dictionaries>` setting with *priority*
+        if not already defined (even as an import string).
+
+        If *cls* is not already defined, it is set regardless of the priority
+        of the *name* setting. The setting priority is not affected by this
+        change either.
+        """
+        component_priority_dict = self.getdict(name)
+        for cls_or_path in tuple(component_priority_dict):
+            if load_object(cls_or_path) == cls:
+                return
+        component_priority_dict[cls] = priority
+        self.set(name, component_priority_dict, self.getpriority(name) or 0)
+
     def setdict(self, values: _SettingsInputT, priority: int | str = "project") -> None:
         self.update(values, priority)
 
diff --git a/scrapy/utils/conf.py b/scrapy/utils/conf.py
index a86aad51c..891cbb485 100644
--- a/scrapy/utils/conf.py
+++ b/scrapy/utils/conf.py
@@ -22,7 +22,8 @@ def build_component_list(
     *,
     convert: Callable[[Any], Any] = update_classpath,
 ) -> list[Any]:
-    """Compose a component list from a { class: order } dictionary."""
+    """Compose a component list from a :ref:`component priority dictionary
+    <component-priority-dictionaries>`."""
 
     def _check_components(complist: Collection[Any]) -> None:
         if len({convert(c) for c in complist}) != len(complist):
diff --git a/tox.ini b/tox.ini
index 82ad84c90..041fcffca 100644
--- a/tox.ini
+++ b/tox.ini
@@ -39,7 +39,7 @@ passenv =
 #allow tox virtualenv to upgrade pip/wheel/setuptools
 download = true
 commands =
-    pytest --cov-config=pyproject.toml --cov=scrapy --cov-report=xml --cov-report= {posargs:--durations=10 docs scrapy tests} --doctest-modules
+    pytest --cov-config=pyproject.toml --cov=scrapy --cov-report= --cov-report=term-missing --cov-report=xml {posargs:--durations=10 docs scrapy tests} --doctest-modules
 install_command =
     python -I -m pip install -ctests/upper-constraints.txt {opts} {packages}
 


Test patch:
diff --git a/tests/test_settings/__init__.py b/tests/test_settings/__init__.py
index b7a316eee..909b365a9 100644
--- a/tests/test_settings/__init__.py
+++ b/tests/test_settings/__init__.py
@@ -260,6 +260,7 @@ class BaseSettingsTest(unittest.TestCase):
             "TEST_FLOAT2": "123.45",
             "TEST_LIST1": ["one", "two"],
             "TEST_LIST2": "one,two",
+            "TEST_LIST3": "",
             "TEST_STR": "value",
             "TEST_DICT1": {"key1": "val1", "ke2": 3},
             "TEST_DICT2": '{"key1": "val1", "ke2": 3}',
@@ -292,6 +293,7 @@ class BaseSettingsTest(unittest.TestCase):
         self.assertEqual(settings.getfloat("TEST_FLOATx", 55.0), 55.0)
         self.assertEqual(settings.getlist("TEST_LIST1"), ["one", "two"])
         self.assertEqual(settings.getlist("TEST_LIST2"), ["one", "two"])
+        self.assertEqual(settings.getlist("TEST_LIST3"), [])
         self.assertEqual(settings.getlist("TEST_LISTx"), [])
         self.assertEqual(settings.getlist("TEST_LISTx", ["default"]), ["default"])
         self.assertEqual(settings["TEST_STR"], "value")
@@ -504,3 +506,591 @@ class SettingsTest(unittest.TestCase):
             TypeError, match="Trying to modify an immutable Settings object"
         ):
             settings.pop("OTHER_DUMMY_CONFIG")
+
+
+@pytest.mark.parametrize(
+    ("before", "name", "item", "after"),
+    [
+        ({}, "FOO", "BAR", {"FOO": ["BAR"]}),
+        ({"FOO": []}, "FOO", "BAR", {"FOO": ["BAR"]}),
+        ({"FOO": ["BAR"]}, "FOO", "BAZ", {"FOO": ["BAR", "BAZ"]}),
+        ({"FOO": ["BAR"]}, "FOO", "BAR", {"FOO": ["BAR"]}),
+        ({"FOO": ""}, "FOO", "BAR", {"FOO": ["BAR"]}),
+        ({"FOO": "BAR"}, "FOO", "BAR", {"FOO": "BAR"}),
+        ({"FOO": "BAR"}, "FOO", "BAZ", {"FOO": ["BAR", "BAZ"]}),
+        ({"FOO": "BAR,BAZ"}, "FOO", "BAZ", {"FOO": "BAR,BAZ"}),
+        ({"FOO": "BAR,BAZ"}, "FOO", "QUX", {"FOO": ["BAR", "BAZ", "QUX"]}),
+    ],
+)
+def test_add_to_list(before, name, item, after):
+    settings = BaseSettings(before, priority=0)
+    settings.add_to_list(name, item)
+    expected_priority = settings.getpriority(name) or 0
+    expected_settings = BaseSettings(after, priority=expected_priority)
+    assert settings == expected_settings, (
+        f"{settings[name]=} != {expected_settings[name]=}"
+    )
+    assert settings.getpriority(name) == expected_settings.getpriority(name)
+
+
+@pytest.mark.parametrize(
+    ("before", "name", "item", "after"),
+    [
+        ({}, "FOO", "BAR", ValueError),
+        ({"FOO": ["BAR"]}, "FOO", "BAR", {"FOO": []}),
+        ({"FOO": ["BAR"]}, "FOO", "BAZ", ValueError),
+        ({"FOO": ["BAR", "BAZ"]}, "FOO", "BAR", {"FOO": ["BAZ"]}),
+        ({"FOO": ""}, "FOO", "BAR", ValueError),
+        ({"FOO": "[]"}, "FOO", "BAR", ValueError),
+        ({"FOO": "BAR"}, "FOO", "BAR", {"FOO": []}),
+        ({"FOO": "BAR"}, "FOO", "BAZ", ValueError),
+        ({"FOO": "BAR,BAZ"}, "FOO", "BAR", {"FOO": ["BAZ"]}),
+    ],
+)
+def test_remove_from_list(before, name, item, after):
+    settings = BaseSettings(before, priority=0)
+
+    if isinstance(after, type) and issubclass(after, Exception):
+        with pytest.raises(after):
+            settings.remove_from_list(name, item)
+        return
+
+    settings.remove_from_list(name, item)
+    expected_priority = settings.getpriority(name) or 0
+    expected_settings = BaseSettings(after, priority=expected_priority)
+    assert settings == expected_settings, (
+        f"{settings[name]=} != {expected_settings[name]=}"
+    )
+    assert settings.getpriority(name) == expected_settings.getpriority(name)
+
+
+class Component1:
+    pass
+
+
+Component1Alias = Component1
+
+
+class Component1Subclass(Component1):
+    pass
+
+
+Component1SubclassAlias = Component1Subclass
+
+
+class Component2:
+    pass
+
+
+class Component3:
+    pass
+
+
+class Component4:
+    pass
+
+
+@pytest.mark.parametrize(
+    ("before", "name", "old_cls", "new_cls", "priority", "after"),
+    [
+        ({}, "FOO", Component1, Component2, None, KeyError),
+        (
+            {"FOO": {Component1: 1}},
+            "FOO",
+            Component1,
+            Component2,
+            None,
+            {"FOO": {Component2: 1}},
+        ),
+        (
+            {"FOO": {Component1: 1}},
+            "FOO",
+            Component1,
+            Component2,
+            2,
+            {"FOO": {Component2: 2}},
+        ),
+        (
+            {"FOO": {"tests.test_settings.Component1": 1}},
+            "FOO",
+            Component1,
+            Component2,
+            None,
+            {"FOO": {Component2: 1}},
+        ),
+        (
+            {"FOO": {Component1Alias: 1}},
+            "FOO",
+            Component1,
+            Component2,
+            None,
+            {"FOO": {Component2: 1}},
+        ),
+        (
+            {"FOO": {Component1Alias: 1}},
+            "FOO",
+            Component1,
+            Component2,
+            2,
+            {"FOO": {Component2: 2}},
+        ),
+        (
+            {"FOO": {"tests.test_settings.Component1Alias": 1}},
+            "FOO",
+            Component1,
+            Component2,
+            None,
+            {"FOO": {Component2: 1}},
+        ),
+        (
+            {"FOO": {"tests.test_settings.Component1Alias": 1}},
+            "FOO",
+            Component1,
+            Component2,
+            2,
+            {"FOO": {Component2: 2}},
+        ),
+        (
+            {
+                "FOO": {
+                    "tests.test_settings.Component1": 1,
+                    "tests.test_settings.Component1Alias": 2,
+                }
+            },
+            "FOO",
+            Component1,
+            Component2,
+            None,
+            {"FOO": {Component2: 2}},
+        ),
+        (
+            {
+                "FOO": {
+                    "tests.test_settings.Component1": 1,
+                    "tests.test_settings.Component1Alias": 2,
+                }
+            },
+            "FOO",
+            Component1,
+            Component2,
+            3,
+            {"FOO": {Component2: 3}},
+        ),
+        (
+            {"FOO": '{"tests.test_settings.Component1": 1}'},
+            "FOO",
+            Component1,
+            Component2,
+            None,
+            {"FOO": {Component2: 1}},
+        ),
+        (
+            {"FOO": '{"tests.test_settings.Component1": 1}'},
+            "FOO",
+            Component1,
+            Component2,
+            2,
+            {"FOO": {Component2: 2}},
+        ),
+        (
+            {"FOO": '{"tests.test_settings.Component1Alias": 1}'},
+            "FOO",
+            Component1,
+            Component2,
+            None,
+            {"FOO": {Component2: 1}},
+        ),
+        (
+            {"FOO": '{"tests.test_settings.Component1Alias": 1}'},
+            "FOO",
+            Component1,
+            Component2,
+            2,
+            {"FOO": {Component2: 2}},
+        ),
+        (
+            {
+                "FOO": '{"tests.test_settings.Component1": 1, "tests.test_settings.Component1Alias": 2}'
+            },
+            "FOO",
+            Component1,
+            Component2,
+            None,
+            {"FOO": {Component2: 2}},
+        ),
+        (
+            {
+                "FOO": '{"tests.test_settings.Component1": 1, "tests.test_settings.Component1Alias": 2}'
+            },
+            "FOO",
+            Component1,
+            Component2,
+            3,
+            {"FOO": {Component2: 3}},
+        ),
+        # If old_cls has None as value, raise KeyError.
+        (
+            {"FOO": {Component1: None}},
+            "FOO",
+            Component1,
+            Component2,
+            None,
+            KeyError,
+        ),
+        (
+            {"FOO": '{"tests.test_settings.Component1": null}'},
+            "FOO",
+            Component1,
+            Component2,
+            None,
+            KeyError,
+        ),
+        (
+            {"FOO": {Component1: None, "tests.test_settings.Component1": None}},
+            "FOO",
+            Component1,
+            Component2,
+            None,
+            KeyError,
+        ),
+        (
+            {"FOO": {Component1: 1, "tests.test_settings.Component1": None}},
+            "FOO",
+            Component1,
+            Component2,
+            None,
+            KeyError,
+        ),
+        (
+            {"FOO": {Component1: None, "tests.test_settings.Component1": 1}},
+            "FOO",
+            Component1,
+            Component2,
+            None,
+            KeyError,
+        ),
+        # Unrelated components are kept as is, as expected.
+        (
+            {
+                "FOO": {
+                    Component1: 1,
+                    "tests.test_settings.Component2": 2,
+                    Component3: 3,
+                }
+            },
+            "FOO",
+            Component3,
+            Component4,
+            None,
+            {
+                "FOO": {
+                    Component1: 1,
+                    "tests.test_settings.Component2": 2,
+                    Component4: 3,
+                }
+            },
+        ),
+    ],
+)
+def test_replace_in_component_priority_dict(
+    before, name, old_cls, new_cls, priority, after
+):
+    settings = BaseSettings(before, priority=0)
+
+    if isinstance(after, type) and issubclass(after, Exception):
+        with pytest.raises(after):
+            settings.replace_in_component_priority_dict(
+                name, old_cls, new_cls, priority
+            )
+        return
+
+    expected_priority = settings.getpriority(name) or 0
+    settings.replace_in_component_priority_dict(name, old_cls, new_cls, priority)
+    expected_settings = BaseSettings(after, priority=expected_priority)
+    assert settings == expected_settings
+    assert settings.getpriority(name) == expected_settings.getpriority(name)
+
+
+@pytest.mark.parametrize(
+    ("before", "name", "cls", "priority", "after"),
+    [
+        # Set
+        ({}, "FOO", Component1, None, {"FOO": {Component1: None}}),
+        ({}, "FOO", Component1, 0, {"FOO": {Component1: 0}}),
+        ({}, "FOO", Component1, 1, {"FOO": {Component1: 1}}),
+        # Add
+        (
+            {"FOO": {Component1: 0}},
+            "FOO",
+            Component2,
+            None,
+            {"FOO": {Component1: 0, Component2: None}},
+        ),
+        (
+            {"FOO": {Component1: 0}},
+            "FOO",
+            Component2,
+            0,
+            {"FOO": {Component1: 0, Component2: 0}},
+        ),
+        (
+            {"FOO": {Component1: 0}},
+            "FOO",
+            Component2,
+            1,
+            {"FOO": {Component1: 0, Component2: 1}},
+        ),
+        # Replace
+        (
+            {
+                "FOO": {
+                    Component1: None,
+                    "tests.test_settings.Component1": 0,
+                    "tests.test_settings.Component1Alias": 1,
+                    Component1Subclass: None,
+                    "tests.test_settings.Component1Subclass": 0,
+                    "tests.test_settings.Component1SubclassAlias": 1,
+                }
+            },
+            "FOO",
+            Component1,
+            None,
+            {
+                "FOO": {
+                    Component1: None,
+                    Component1Subclass: None,
+                    "tests.test_settings.Component1Subclass": 0,
+                    "tests.test_settings.Component1SubclassAlias": 1,
+                }
+            },
+        ),
+        (
+            {
+                "FOO": {
+                    Component1: 0,
+                    "tests.test_settings.Component1": 1,
+                    "tests.test_settings.Component1Alias": None,
+                    Component1Subclass: 0,
+                    "tests.test_settings.Component1Subclass": 1,
+                    "tests.test_settings.Component1SubclassAlias": None,
+                }
+            },
+            "FOO",
+            Component1,
+            0,
+            {
+                "FOO": {
+                    Component1: 0,
+                    Component1Subclass: 0,
+                    "tests.test_settings.Component1Subclass": 1,
+                    "tests.test_settings.Component1SubclassAlias": None,
+                }
+            },
+        ),
+        (
+            {
+                "FOO": {
+                    Component1: 1,
+                    "tests.test_settings.Component1": None,
+                    "tests.test_settings.Component1Alias": 0,
+                    Component1Subclass: 1,
+                    "tests.test_settings.Component1Subclass": None,
+                    "tests.test_settings.Component1SubclassAlias": 0,
+                }
+            },
+            "FOO",
+            Component1,
+            1,
+            {
+                "FOO": {
+                    Component1: 1,
+                    Component1Subclass: 1,
+                    "tests.test_settings.Component1Subclass": None,
+                    "tests.test_settings.Component1SubclassAlias": 0,
+                }
+            },
+        ),
+        # String-based setting values
+        (
+            {"FOO": '{"tests.test_settings.Component1": 0}'},
+            "FOO",
+            Component2,
+            None,
+            {"FOO": {"tests.test_settings.Component1": 0, Component2: None}},
+        ),
+        (
+            {
+                "FOO": """{
+                    "tests.test_settings.Component1": 0,
+                    "tests.test_settings.Component1Alias": 1,
+                    "tests.test_settings.Component1Subclass": 0,
+                    "tests.test_settings.Component1SubclassAlias": 1
+                }"""
+            },
+            "FOO",
+            Component1,
+            None,
+            {
+                "FOO": {
+                    Component1: None,
+                    "tests.test_settings.Component1Subclass": 0,
+                    "tests.test_settings.Component1SubclassAlias": 1,
+                }
+            },
+        ),
+    ],
+)
+def test_set_in_component_priority_dict(before, name, cls, priority, after):
+    settings = BaseSettings(before, priority=0)
+    expected_priority = settings.getpriority(name) or 0
+    settings.set_in_component_priority_dict(name, cls, priority)
+    expected_settings = BaseSettings(after, priority=expected_priority)
+    assert settings == expected_settings
+    assert settings.getpriority(name) == expected_settings.getpriority(name), (
+        f"{settings.getpriority(name)=} != {expected_settings.getpriority(name)=}"
+    )
+
+
+@pytest.mark.parametrize(
+    ("before", "name", "cls", "priority", "after"),
+    [
+        # Set
+        ({}, "FOO", Component1, None, {"FOO": {Component1: None}}),
+        ({}, "FOO", Component1, 0, {"FOO": {Component1: 0}}),
+        ({}, "FOO", Component1, 1, {"FOO": {Component1: 1}}),
+        # Add
+        (
+            {"FOO": {Component1: 0}},
+            "FOO",
+            Component2,
+            None,
+            {"FOO": {Component1: 0, Component2: None}},
+        ),
+        (
+            {"FOO": {Component1: 0}},
+            "FOO",
+            Component2,
+            0,
+            {"FOO": {Component1: 0, Component2: 0}},
+        ),
+        (
+            {"FOO": {Component1: 0}},
+            "FOO",
+            Component2,
+            1,
+            {"FOO": {Component1: 0, Component2: 1}},
+        ),
+        # Keep
+        (
+            {
+                "FOO": {
+                    Component1: None,
+                    "tests.test_settings.Component1": 0,
+                    "tests.test_settings.Component1Alias": 1,
+                    Component1Subclass: None,
+                    "tests.test_settings.Component1Subclass": 0,
+                    "tests.test_settings.Component1SubclassAlias": 1,
+                }
+            },
+            "FOO",
+            Component1,
+            None,
+            {
+                "FOO": {
+                    Component1: None,
+                    "tests.test_settings.Component1": 0,
+                    "tests.test_settings.Component1Alias": 1,
+                    Component1Subclass: None,
+                    "tests.test_settings.Component1Subclass": 0,
+                    "tests.test_settings.Component1SubclassAlias": 1,
+                }
+            },
+        ),
+        (
+            {
+                "FOO": {
+                    Component1: 0,
+                    "tests.test_settings.Component1": 1,
+                    "tests.test_settings.Component1Alias": None,
+                    Component1Subclass: 0,
+                    "tests.test_settings.Component1Subclass": 1,
+                    "tests.test_settings.Component1SubclassAlias": None,
+                }
+            },
+            "FOO",
+            Component1,
+            0,
+            {
+                "FOO": {
+                    Component1: 0,
+                    "tests.test_settings.Component1": 1,
+                    "tests.test_settings.Component1Alias": None,
+                    Component1Subclass: 0,
+                    "tests.test_settings.Component1Subclass": 1,
+                    "tests.test_settings.Component1SubclassAlias": None,
+                }
+            },
+        ),
+        (
+            {
+                "FOO": {
+                    Component1: 1,
+                    "tests.test_settings.Component1": None,
+                    "tests.test_settings.Component1Alias": 0,
+                    Component1Subclass: 1,
+                    "tests.test_settings.Component1Subclass": None,
+                    "tests.test_settings.Component1SubclassAlias": 0,
+                }
+            },
+            "FOO",
+            Component1,
+            1,
+            {
+                "FOO": {
+                    Component1: 1,
+                    "tests.test_settings.Component1": None,
+                    "tests.test_settings.Component1Alias": 0,
+                    Component1Subclass: 1,
+                    "tests.test_settings.Component1Subclass": None,
+                    "tests.test_settings.Component1SubclassAlias": 0,
+                }
+            },
+        ),
+        # String-based setting values
+        (
+            {"FOO": '{"tests.test_settings.Component1": 0}'},
+            "FOO",
+            Component2,
+            None,
+            {"FOO": {"tests.test_settings.Component1": 0, Component2: None}},
+        ),
+        (
+            {
+                "FOO": """{
+                    "tests.test_settings.Component1": 0,
+                    "tests.test_settings.Component1Alias": 1,
+                    "tests.test_settings.Component1Subclass": 0,
+                    "tests.test_settings.Component1SubclassAlias": 1
+                }"""
+            },
+            "FOO",
+            Component1,
+            None,
+            {
+                "FOO": """{
+                    "tests.test_settings.Component1": 0,
+                    "tests.test_settings.Component1Alias": 1,
+                    "tests.test_settings.Component1Subclass": 0,
+                    "tests.test_settings.Component1SubclassAlias": 1
+                }"""
+            },
+        ),
+    ],
+)
+def test_setdefault_in_component_priority_dict(before, name, cls, priority, after):
+    settings = BaseSettings(before, priority=0)
+    expected_priority = settings.getpriority(name) or 0
+    settings.setdefault_in_component_priority_dict(name, cls, priority)
+    expected_settings = BaseSettings(after, priority=expected_priority)
+    assert settings == expected_settings
+    assert settings.getpriority(name) == expected_settings.getpriority(name)