Repository: kedro-org/kedro

Issue:
Project templating using TemplatedConfigLoader raises ValidationError in 0.18.0
## Description
When following the guide for [Template configuration](https://kedro.readthedocs.io/en/stable/kedro_project_setup/configuration.html#template-configuration), Kedro 0.18.0 raises ValidationError as `kedro.config.templated_config.TemplatedConfigLoader` is not recognized as a subclass of `kedro.config.config.ConfigLoader`.

## Context
This bug hinders me from using the `TemplatedConfigLoader`, according to the documentation.

## Steps to Reproduce
1. Create new Kedro project
```
kedro new --starter=pandas-iris
cd new-kedro-project
```
2. Create and activate venv. Install requirements.
```
python -m venv my_venv
source my_venv/bin/activate
pip install -r src/requirements.txt
```
3. Open the `settings.py` file.
```
nano src/new_kedro_project/settings.py
```
Uncomment lines 28,29,31,32,33 as suggested in the guide. Expected config:
```
# Class that manages how configuration is loaded.
from kedro.config import TemplatedConfigLoader
CONFIG_LOADER_CLASS = TemplatedConfigLoader
# Keyword arguments to pass to the `CONFIG_LOADER_CLASS` constructor.
CONFIG_LOADER_ARGS = {
    "globals_pattern": "*globals.yml",
}
```
4. Run the default pipeline
```
kedro run
```

## Expected Result
Defeault pipeline should run with global config keys overwritten.

## Actual Result
ValidationError is raised.

```
Traceback (most recent call last):
  File "/usr/local/bin/kedro", line 8, in <module>
    sys.exit(main())
  File "/usr/local/lib/python3.9/site-packages/kedro/framework/cli/cli.py", line 205, in main
    cli_collection = KedroCLI(project_path=Path.cwd())
  File "/usr/local/lib/python3.9/site-packages/kedro/framework/cli/cli.py", line 114, in __init__
    self._metadata = bootstrap_project(project_path)
  File "/usr/local/lib/python3.9/site-packages/kedro/framework/startup.py", line 155, in bootstrap_project
    configure_project(metadata.package_name)
  File "/usr/local/lib/python3.9/site-packages/kedro/framework/project/__init__.py", line 166, in configure_project
    settings.configure(settings_module)
  File "/usr/local/lib/python3.9/site-packages/dynaconf/base.py", line 182, in configure
    self._wrapped = Settings(settings_module=settings_module, **kwargs)
  File "/usr/local/lib/python3.9/site-packages/dynaconf/base.py", line 235, in __init__
    self.validators.validate(
  File "/usr/local/lib/python3.9/site-packages/dynaconf/validator.py", line 417, in validate
    validator.validate(self.settings, only=only, exclude=exclude)
  File "/usr/local/lib/python3.9/site-packages/kedro/framework/project/__init__.py", line 35, in validate
    raise ValidationError(
dynaconf.validator.ValidationError: Invalid value `kedro.config.templated_config.TemplatedConfigLoader` received for setting `CONFIG_LOADER_CLASS`. It must be a subclass of `kedro.config.config.ConfigLoader`.
```

## Your Environment
* Kedro version used (`pip show kedro` or `kedro -V`): `0.18.0`
* Python version used (`python -V`): `3.9.10`
* Operating system and version: `macOS 12.3`



Discussion:
Hi @patrikhardin thanks for raising this - we're looking into it. As part of 0.18.x there is a new `AbstractConfigLoader`.
Thanks @patrikhardin. ~This is indeed a mistake where we should have specified `AbstractConfigLoader` instead [here](https://github.com/kedro-org/kedro/blob/main/kedro/framework/project/__init__.py#L61). Not quite sure how this got through our tests, sorry about that~ Actually it's not such a simple fix... see next message.

For the time being, here's a super hacky fix. Use the following in settings.py:
```
# Class that manages how configuration is loaded.
from kedro.config import TemplatedConfigLoader, ConfigLoader
ConfigLoader.__subclasses__ = lambda: [TemplatedConfigLoader]
CONFIG_LOADER_CLASS = TemplatedConfigLoader
# Keyword arguments to pass to the `CONFIG_LOADER_CLASS` constructor.
CONFIG_LOADER_ARGS = {
    "globals_pattern": "*globals.yml",
}
```

This will trick the validation code into thinking that `TemplatedConfigLoader` is a subclass of `ConfigLoader`.
Here's a new version of test_settings.py that picks up on this and also does some more thorough testing of other settings:  https://gist.github.com/AntonyMilneQB/50159cbbde40399fc6d9094fef3b0fee

The problem is that it's not immediately possible to get both tests passing simultaneously. Changing `ConfigLoader` to `AbstractConfigLoader` doesn't work because then it will change the default value for `CONFIG_LOADER_CLASS` to `AbstractConfigLoader`, which isn't what we want. Basically `_IsSubclassValidator` is designed to treat `default` as the thing that must be the superclass also. We need to modify this somehow:
* make it so we can specify `default` and the superclass independently?
* change the validation so that it just checks that the class specified shares some non-trivial parent class with `default` (sounds less rigorous but maybe easier to implement)?
* just make the validation much slacker so we don't check for subclasses at all? Easy to do just by changing `_IsSubclassValidator` to `Validator`

We were doing the last of these options before #1064 which changed `Validator` to `_IsSubclassValidator` and inadvertently introduced this bug. I think we need to work out out whether we actually care about checking for subclass validation.
Related thread on Discord:

https://discord.com/channels/778216384475693066/846330075535769601/963057365282877440
https://discord.com/channels/778216384475693066/778998585454755870/961547016708702209
https://discord.com/channels/778216384475693066/846330075535769601/960753695870570516

Resolving patch:
diff --git a/kedro/framework/project/__init__.py b/kedro/framework/project/__init__.py
--- a/kedro/framework/project/__init__.py
+++ b/kedro/framework/project/__init__.py
@@ -39,6 +39,35 @@ def validate(self, settings, *args, **kwargs):
                 )
 
 
+class _HasSharedParentClassValidator(Validator):
+    """A validator to check that the parent of the default class is an ancestor of
+    the settings value."""
+
+    def validate(self, settings, *args, **kwargs):
+        super().validate(settings, *args, **kwargs)
+
+        default_class = self.default(settings, self)
+        for name in self.names:
+            setting_value = getattr(settings, name)
+            # In the case of ConfigLoader, default_class.mro() will be:
+            # [kedro.config.config.ConfigLoader,
+            # kedro.config.abstract_config.AbstractConfigLoader,
+            # abc.ABC,
+            # object]
+            # We pick out the direct parent and check if it's in any of the ancestors of
+            # the supplied setting_value. This assumes that the direct parent is
+            # the abstract class that must be inherited from.
+            # A more general check just for a shared ancestor would be:
+            # set(default_class.mro()) & set(setting_value.mro()) - {abc.ABC, object}
+            default_class_parent = default_class.mro()[1]
+            if default_class_parent not in setting_value.mro():
+                raise ValidationError(
+                    f"Invalid value `{setting_value.__module__}.{setting_value.__qualname__}` "
+                    f"received for setting `{name}`. It must be a subclass of "
+                    f"`{default_class_parent.__module__}.{default_class_parent.__qualname__}`."
+                )
+
+
 class _ProjectSettings(LazySettings):
     """Define all settings available for users to configure in Kedro,
     along with their validation rules and default values.
@@ -57,7 +86,7 @@ class _ProjectSettings(LazySettings):
     )
     _SESSION_STORE_ARGS = Validator("SESSION_STORE_ARGS", default={})
     _DISABLE_HOOKS_FOR_PLUGINS = Validator("DISABLE_HOOKS_FOR_PLUGINS", default=tuple())
-    _CONFIG_LOADER_CLASS = _IsSubclassValidator(
+    _CONFIG_LOADER_CLASS = _HasSharedParentClassValidator(
         "CONFIG_LOADER_CLASS", default=_get_default_class("kedro.config.ConfigLoader")
     )
     _CONFIG_LOADER_ARGS = Validator("CONFIG_LOADER_ARGS", default={})


Test patch:
diff --git a/tests/extras/datasets/spark/conftest.py b/tests/extras/datasets/spark/conftest.py
--- a/tests/extras/datasets/spark/conftest.py
+++ b/tests/extras/datasets/spark/conftest.py
@@ -35,7 +35,7 @@ def spark_session(tmp_path_factory):
     # https://github.com/pytest-dev/pytest-xdist#making-session-scoped-fixtures-execute-only-once
     root_tmp_dir = tmp_path_factory.getbasetemp().parent
     lock = root_tmp_dir / "semaphore.lock"
-    with FileLock(lock):  # pylint: disable=abstract-class-instantiated
+    with FileLock(lock):
         spark = _setup_spark_session()
     yield spark
     spark.stop()
diff --git a/tests/framework/project/test_settings.py b/tests/framework/project/test_settings.py
--- a/tests/framework/project/test_settings.py
+++ b/tests/framework/project/test_settings.py
@@ -3,29 +3,63 @@
 
 import pytest
 
+from kedro.config import ConfigLoader, TemplatedConfigLoader
 from kedro.framework.context.context import KedroContext
 from kedro.framework.project import configure_project, settings
-from kedro.framework.session.store import BaseSessionStore
+from kedro.framework.session.store import BaseSessionStore, ShelveStore
+from kedro.io import DataCatalog
 
 
 class MyContext(KedroContext):
     pass
 
 
+class MyDataCatalog(DataCatalog):
+    pass
+
+
+class ProjectHooks:  # pylint: disable=too-few-public-methods
+    pass
+
+
 @pytest.fixture
 def mock_package_name_with_settings_file(tmpdir):
+    """This mock settings file tests everything that can be customised in settings.py.
+    Where there are suggestions in the project template settings.py (e.g. as for
+    CONFIG_LOADER_CLASS), those suggestions should be tested."""
     old_settings = settings.as_dict()
     settings_file_path = tmpdir.mkdir("test_package").join("settings.py")
+    project_path, package_name, _ = str(settings_file_path).rpartition("test_package")
     settings_file_path.write(
         textwrap.dedent(
             f"""
+                from {__name__} import ProjectHooks
+                HOOKS = (ProjectHooks(),)
+
+                DISABLE_HOOKS_FOR_PLUGINS = ("kedro-viz",)
+
+                from kedro.framework.session.store import ShelveStore
+                SESSION_STORE_CLASS = ShelveStore
+                SESSION_STORE_ARGS = {{
+                    "path": "./sessions"
+                }}
+
                 from {__name__} import MyContext
-                CONF_SOURCE = "test_conf"
                 CONTEXT_CLASS = MyContext
+                CONF_SOURCE = "test_conf"
+
+                from kedro.config import TemplatedConfigLoader
+                CONFIG_LOADER_CLASS = TemplatedConfigLoader
+                CONFIG_LOADER_ARGS = {{
+                     "globals_pattern": "*globals.yml",
+                }}
+
+                # Class that manages the Data Catalog.
+                from {__name__} import MyDataCatalog
+                DATA_CATALOG_CLASS = MyDataCatalog
             """
         )
     )
-    project_path, package_name, _ = str(settings_file_path).rpartition("test_package")
     sys.path.insert(0, project_path)
     yield package_name
     sys.path.pop(0)
@@ -34,17 +68,28 @@ def mock_package_name_with_settings_file(tmpdir):
         settings.set(key, value)
 
 
-def test_settings_without_configure_project_show_default_values():
-    assert settings.CONF_SOURCE == "conf"
-    assert settings.CONTEXT_CLASS is KedroContext
+def test_settings_without_configure_project_shows_default_values():
+    assert len(settings.HOOKS) == 0
+    assert settings.DISABLE_HOOKS_FOR_PLUGINS.to_list() == []
     assert settings.SESSION_STORE_CLASS is BaseSessionStore
     assert settings.SESSION_STORE_ARGS == {}
-    assert len(settings.DISABLE_HOOKS_FOR_PLUGINS) == 0
+    assert settings.CONTEXT_CLASS is KedroContext
+    assert settings.CONF_SOURCE == "conf"
+    assert settings.CONFIG_LOADER_CLASS == ConfigLoader
+    assert settings.CONFIG_LOADER_ARGS == {}
+    assert settings.DATA_CATALOG_CLASS == DataCatalog
 
 
 def test_settings_after_configuring_project_shows_updated_values(
     mock_package_name_with_settings_file,
 ):
     configure_project(mock_package_name_with_settings_file)
-    assert settings.CONF_SOURCE == "test_conf"
+    assert len(settings.HOOKS) == 1 and isinstance(settings.HOOKS[0], ProjectHooks)
+    assert settings.DISABLE_HOOKS_FOR_PLUGINS.to_list() == ["kedro-viz"]
+    assert settings.SESSION_STORE_CLASS is ShelveStore
+    assert settings.SESSION_STORE_ARGS == {"path": "./sessions"}
     assert settings.CONTEXT_CLASS is MyContext
+    assert settings.CONF_SOURCE == "test_conf"
+    assert settings.CONFIG_LOADER_CLASS == TemplatedConfigLoader
+    assert settings.CONFIG_LOADER_ARGS == {"globals_pattern": "*globals.yml"}
+    assert settings.DATA_CATALOG_CLASS == MyDataCatalog
diff --git a/tests/framework/session/test_session.py b/tests/framework/session/test_session.py
--- a/tests/framework/session/test_session.py
+++ b/tests/framework/session/test_session.py
@@ -9,11 +9,12 @@
 import toml
 
 from kedro import __version__ as kedro_version
-from kedro.config import ConfigLoader
+from kedro.config import AbstractConfigLoader, ConfigLoader
 from kedro.framework.context import KedroContext
 from kedro.framework.project import (
     ValidationError,
     Validator,
+    _HasSharedParentClassValidator,
     _IsSubclassValidator,
     _ProjectSettings,
     configure_project,
@@ -33,7 +34,7 @@ class BadStore:  # pylint: disable=too-few-public-methods
 
 class BadConfigLoader:  # pylint: disable=too-few-public-methods
     """
-    ConfigLoader class that doesn't subclass `ConfigLoader`, for testing only.
+    ConfigLoader class that doesn't subclass `AbstractConfigLoader`, for testing only.
     """
 
 
@@ -90,7 +91,7 @@ class MyConfigLoader(ConfigLoader):
         pass
 
     class MockSettings(_ProjectSettings):
-        _CONFIG_LOADER_CLASS = _IsSubclassValidator(
+        _CONFIG_LOADER_CLASS = _HasSharedParentClassValidator(
             "CONFIG_LOADER_CLASS", default=lambda *_: MyConfigLoader
         )
 
@@ -359,14 +360,14 @@ def test_load_config_loader_custom_config_loader_class(
         session = KedroSession.create(mock_package_name, fake_project)
         result = session._get_config_loader()
 
-        assert isinstance(result, ConfigLoader)
+        assert isinstance(result, AbstractConfigLoader)
         assert result.__class__.__name__ == "MyConfigLoader"
 
     def test_broken_config_loader(self, mock_settings_file_bad_config_loader_class):
         pattern = (
             "Invalid value `tests.framework.session.test_session.BadConfigLoader` received "
             "for setting `CONFIG_LOADER_CLASS`. "
-            "It must be a subclass of `kedro.config.config.ConfigLoader`."
+            "It must be a subclass of `kedro.config.abstract_config.AbstractConfigLoader`."
         )
         mock_settings = _ProjectSettings(
             settings_file=str(mock_settings_file_bad_config_loader_class)