Repository: meltano/sdk
Pull request #2790 (2024-12-02 15:51:39)

fix: Ensure the required global folder tap settings are merged into the concrete implementation settings


<!-- readthedocs-preview meltano-sdk start -->
----
📚 Documentation preview 📚: https://meltano-sdk--2790.org.readthedocs.build/en/2790/

<!-- readthedocs-preview meltano-sdk end -->

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/meltano/sdk/pull/2790?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) Report
Attention: Patch coverage is `81.81818%` with `2 lines` in your changes missing coverage. Please review.
> Project coverage is 91.03%. Comparing base [(`a553c38`)](https://app.codecov.io/gh/meltano/sdk/commit/a553c38d215491e2040387ad9c2be882a9232329?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) to head [(`d9648f5`)](https://app.codecov.io/gh/meltano/sdk/commit/d9648f57326a109fe463364dc64247b3da068783?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano).

| [Files with missing lines](https://app.codecov.io/gh/meltano/sdk/pull/2790?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) | Patch % | Lines |
|---|---|---|
| [singer\_sdk/plugin\_base.py](https://app.codecov.io/gh/meltano/sdk/pull/2790?src=pr&el=tree&filepath=singer_sdk%2Fplugin_base.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano#diff-c2luZ2VyX3Nkay9wbHVnaW5fYmFzZS5weQ==) | 33.33% | [1 Missing and 1 partial :warning: ](https://app.codecov.io/gh/meltano/sdk/pull/2790?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) |

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


```diff
@@           Coverage Diff           @@
##             main    #2790   +/-   ##
=======================================
  Coverage   91.02%   91.03%           
=======================================
  Files          62       62           
  Lines        5170     5173    +3     
  Branches      666      665    -1     
=======================================
+ Hits         4706     4709    +3     
  Misses        326      326           
  Partials      138      138           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/meltano/sdk/pull/2790?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano).


Patch:
diff --git a/singer_sdk/contrib/filesystem/tap.py b/singer_sdk/contrib/filesystem/tap.py
index ff0c9881..6c957921 100644
--- a/singer_sdk/contrib/filesystem/tap.py
+++ b/singer_sdk/contrib/filesystem/tap.py
@@ -105,6 +105,8 @@ class FolderTap(Tap, t.Generic[_T]):
     This should be a subclass of `FileStream`.
     """
 
+    dynamic_catalog: bool = True
+
     config_jsonschema: t.ClassVar[dict] = {"properties": {}}
 
     @classmethod
@@ -124,11 +126,16 @@ class FolderTap(Tap, t.Generic[_T]):
             config_jsonschema: [description]
         """
 
-        def _merge_missing(source_jsonschema: dict, target_jsonschema: dict) -> None:
+        def _merge_missing(src: dict, tgt: dict) -> None:
             # Append any missing properties in the target with those from source.
-            for k, v in source_jsonschema["properties"].items():
-                if k not in target_jsonschema["properties"]:
-                    target_jsonschema["properties"][k] = v
+            for k, v in src["properties"].items():
+                if k not in tgt["properties"]:
+                    tgt["properties"][k] = v
+
+                # Merge the required fields
+                source_required = src.get("required", [])
+                target_required = tgt.get("required", [])
+                tgt["required"] = list(set(source_required + target_required))
 
         _merge_missing(BASE_CONFIG_SCHEMA, config_jsonschema)
 
diff --git a/singer_sdk/plugin_base.py b/singer_sdk/plugin_base.py
index 1b8735f2..62f72f80 100644
--- a/singer_sdk/plugin_base.py
+++ b/singer_sdk/plugin_base.py
@@ -160,14 +160,13 @@ class PluginBase(metaclass=abc.ABCMeta):  # noqa: PLR0904
             validate_config: True to require validation of config settings.
 
         Raises:
-            ValueError: If config is not a dict or path string.
+            TypeError: If config is not a dict or path string.
         """
-        if not config:
-            config_dict = {}
-        elif isinstance(config, (str, PurePath)):
+        config = config or {}
+        if isinstance(config, (str, PurePath)):
             config_dict = read_json_file(config)
             warnings.warn(
-                "Passsing a config file path is deprecated. Please pass the config "
+                "Passing a config file path is deprecated. Please pass the config "
                 "as a dictionary instead.",
                 SingerSDKDeprecationWarning,
                 stacklevel=2,
@@ -179,7 +178,7 @@ class PluginBase(metaclass=abc.ABCMeta):  # noqa: PLR0904
                 # list will override those of earlier ones.
                 config_dict.update(read_json_file(config_path))
             warnings.warn(
-                "Passsing a list of config file paths is deprecated. Please pass the "
+                "Passing a list of config file paths is deprecated. Please pass the "
                 "config as a dictionary instead.",
                 SingerSDKDeprecationWarning,
                 stacklevel=2,
@@ -188,7 +187,7 @@ class PluginBase(metaclass=abc.ABCMeta):  # noqa: PLR0904
             config_dict = config
         else:
             msg = f"Error parsing config of type '{type(config).__name__}'."  # type: ignore[unreachable]
-            raise ValueError(msg)
+            raise TypeError(msg)
         if parse_env_config:
             self.logger.info("Parsing env var for settings config...")
             config_dict.update(self._env_var_config)
diff --git a/singer_sdk/tap_base.py b/singer_sdk/tap_base.py
index c26fb823..cccbe4fd 100644
--- a/singer_sdk/tap_base.py
+++ b/singer_sdk/tap_base.py
@@ -106,7 +106,7 @@ class Tap(PluginBase, SingerWriter, metaclass=abc.ABCMeta):  # noqa: PLR0904
         elif catalog is not None:
             self._input_catalog = Catalog.from_dict(read_json_file(catalog))
             warnings.warn(
-                "Passsing a catalog file path is deprecated. Please pass the catalog "
+                "Passing a catalog file path is deprecated. Please pass the catalog "
                 "as a dictionary or Catalog object instead.",
                 SingerSDKDeprecationWarning,
                 stacklevel=2,
@@ -124,7 +124,7 @@ class Tap(PluginBase, SingerWriter, metaclass=abc.ABCMeta):  # noqa: PLR0904
         elif state:
             state_dict = read_json_file(state)
             warnings.warn(
-                "Passsing a state file path is deprecated. Please pass the state "
+                "Passing a state file path is deprecated. Please pass the state "
                 "as a dictionary instead.",
                 SingerSDKDeprecationWarning,
                 stacklevel=2,


Test patch:
diff --git a/tests/core/test_plugin_base.py b/tests/core/test_plugin_base.py
index 04b6a166..7f47435c 100644
--- a/tests/core/test_plugin_base.py
+++ b/tests/core/test_plugin_base.py
@@ -1,10 +1,15 @@
 from __future__ import annotations
 
+import typing as t
+
 import pytest
 
 from singer_sdk.plugin_base import SDK_PACKAGE_NAME, MapperNotInitialized, PluginBase
 from singer_sdk.typing import IntegerType, PropertiesList, Property, StringType
 
+if t.TYPE_CHECKING:
+    from pathlib import Path
+
 
 class PluginTest(PluginBase):
     """Example Plugin for tests."""
@@ -16,6 +21,24 @@ class PluginTest(PluginBase):
     ).to_dict()
 
 
+def test_config_path(tmp_path: Path):
+    """Test that the config path is correctly set."""
+    config_json = '{"prop1": "hello", "prop2": 123}'
+    config_path = tmp_path / "config.json"
+    config_path.write_text(config_json)
+
+    with pytest.deprecated_call():
+        plugin = PluginTest(config=config_path)
+
+    assert plugin.config == {"prop1": "hello", "prop2": 123}
+
+
+def test_invalid_config_type():
+    """Test that invalid config types raise an error."""
+    with pytest.raises(TypeError, match="Error parsing config of type 'tuple'"):
+        PluginTest(config=(("prop1", "hello"), ("prop2", 123)))
+
+
 def test_get_env_var_config(monkeypatch: pytest.MonkeyPatch):
     """Test settings parsing from environment variables."""
     monkeypatch.delenv("PLUGIN_TEST_PROP1", raising=False)