Repository: meltano/meltano

Issue:
fix: Avoid logging `kind: {kind}` is deprecated...` if the corresponding replacement is in place
This would make the migration smoother since the Hub could do a batch update that's backwards compatible. Older versions of Meltano (I think, yet to confirm) wouldn't crash if an unknown setting attribute such as `sensitive` is present.

---

@danielptv I think keeping both is the most sensible. We could update Meltano to only log the warnings when the corresponding future replacement is not in place.

_Originally posted by @edgarrmondragon in https://github.com/meltano/hub/pull/1675#issuecomment-1948857377_


Discussion:
Confirmed that Meltano 2.x doesn't complain about `sensitive: true` being present in the plugin definition.

<details><summary>Installing and preserving Meltano 2.x</summary>
<p>

```console
$ pipx install 'meltano==2.*' --python python3.11 --suffix=@2                                                                                                                                                                                                        
  installed package meltano 2.20.0 (meltano@2), installed using Python 3.11.7
  These apps are now globally available
    - meltano@2
done! ✨ 🌟 ✨

$ pipx upgrade-all --skip meltano@2                                                                                                                                                                                                                                  
Versions did not change after running 'pipx upgrade' for each package 😴

$ meltano@2 --version                                                                                                                                                                                                                                                
meltano, version 2.20.0
```

</p>
</details> 
The batch update in question: https://github.com/meltano/hub/pull/1696. Thanks to @pnadolny13!

Resolving patch:
diff --git a/src/meltano/core/setting_definition.py b/src/meltano/core/setting_definition.py
--- a/src/meltano/core/setting_definition.py
+++ b/src/meltano/core/setting_definition.py
@@ -16,7 +16,7 @@
 from meltano.core.error import Error
 
 if t.TYPE_CHECKING:
-    from ruamel.yaml import Representer
+    from ruamel.yaml import Node, Representer, ScalarNode
 
 VALUE_PROCESSORS = {
     "nest_object": utils.nest_object,
@@ -28,7 +28,7 @@
 class EnvVar:
     """Environment Variable class."""
 
-    def __init__(self, definition: str):
+    def __init__(self, definition: str) -> None:
         """Instantiate new EnvVar.
 
         Args:
@@ -70,7 +70,7 @@ def get(self, env) -> str:
 class SettingMissingError(Error):
     """A setting is missing."""
 
-    def __init__(self, name: str):
+    def __init__(self, name: str) -> None:
         """Instantiate SettingMissingError.
 
         Args:
@@ -82,7 +82,7 @@ def __init__(self, name: str):
 class YAMLEnum(str, Enum):
     """Serializable Enum class."""
 
-    def __str__(self):
+    def __str__(self) -> str:
         """Return as string.
 
         Returns:
@@ -104,7 +104,7 @@ def yaml_representer(dumper, obj) -> str:
         return dumper.represent_scalar("tag:yaml.org,2002:str", str(obj))
 
     @classmethod
-    def to_yaml(cls, representer: Representer, node: t.Any):
+    def to_yaml(cls, representer: Representer, node: t.Any) -> ScalarNode:
         """Represent as yaml.
 
         Args:
@@ -120,8 +120,8 @@ def to_yaml(cls, representer: Representer, node: t.Any):
     def from_yaml(
         cls,
         constructor,  # noqa: ARG003
-        node,
-    ):
+        node: Node,
+    ) -> YAMLEnum:
         """Construct from yaml.
 
         Args:
@@ -151,7 +151,7 @@ class SettingKind(YAMLEnum):
     HIDDEN = "hidden"
 
     @cached_property
-    def is_sensitive(self):
+    def is_sensitive(self) -> bool:
         """Return whether the setting kind is sensitive.
 
         Returns:
@@ -169,6 +169,12 @@ def is_sensitive(self):
 class SettingDefinition(NameEq, Canonical):
     """Meltano SettingDefinition class."""
 
+    name: str
+    kind: SettingKind | None
+    hidden: bool
+    sensitive: bool
+    _custom: bool
+
     def __init__(
         self,
         name: str | None = None,
@@ -225,8 +231,24 @@ def __init__(
         oauth = oauth or {}
 
         kind = SettingKind(kind) if kind else None
-        hidden = hidden or kind is SettingKind.HIDDEN or None
-        sensitive = sensitive or kind and kind.is_sensitive or None
+
+        # Handle deprecated SettingKind.HIDDEN
+        if kind is SettingKind.HIDDEN:
+            # Override kind if hidden flag is set
+            if hidden:
+                kind = SettingKind.STRING
+
+            # Prioritize kind over flag otherwise
+            hidden = True
+
+        # Handle deprecated SettingKind.PASSWORD and SettingKind.OAUTH
+        if kind and kind.is_sensitive:
+            # Override kind if sensitive flag is set
+            if sensitive:
+                kind = SettingKind.STRING
+
+            # Prioritize kind over flag otherwise
+            sensitive = True
 
         super().__init__(
             # Attributes will be listed in meltano.yml in this order:
@@ -263,7 +285,12 @@ def __repr__(self) -> str:
         return f"<SettingDefinition {self.name} ({self.kind})>"
 
     @classmethod
-    def from_missing(cls, defs: t.Iterable[SettingDefinition], config: dict, **kwargs):
+    def from_missing(
+        cls,
+        defs: t.Iterable[SettingDefinition],
+        config: dict,
+        **kwargs,
+    ) -> list[SettingDefinition]:
         """Create SettingDefinition instances for missing settings.
 
         Args:
@@ -292,7 +319,7 @@ def from_key_value(
         value: t.Any,
         custom: bool = True,
         default: t.Any | bool = False,
-    ):
+    ) -> SettingDefinition:
         """Create SettingDefinition instance from key-value pair.
 
         Args:


Test patch:
diff --git a/tests/meltano/core/test_setting_definition.py b/tests/meltano/core/test_setting_definition.py
--- a/tests/meltano/core/test_setting_definition.py
+++ b/tests/meltano/core/test_setting_definition.py
@@ -71,3 +71,107 @@ def test_cast_options(self):
         assert setting_definition.cast_value(None) is None
         with pytest.raises(ValueError, match="is not a valid choice"):
             setting_definition.cast_value("def")
+
+    @pytest.mark.parametrize(
+        ("setting_definition", "sensitive", "kind"),
+        (
+            pytest.param(
+                SettingDefinition(
+                    "test_setting",
+                    sensitive=True,
+                    kind=SettingKind.STRING,
+                ),
+                True,
+                SettingKind.STRING,
+                id="sensitive-string",
+            ),
+            pytest.param(
+                SettingDefinition(
+                    "test_setting",
+                    sensitive=True,
+                    kind=SettingKind.PASSWORD,
+                ),
+                True,
+                SettingKind.STRING,
+                id="sensitive-password",
+            ),
+            pytest.param(
+                SettingDefinition(
+                    "test_setting",
+                    sensitive=False,
+                    kind=None,
+                ),
+                False,
+                None,
+                id="sensitive-no-kind",
+            ),
+            pytest.param(
+                SettingDefinition(
+                    "test_setting",
+                    sensitive=False,
+                    kind=SettingKind.STRING,
+                ),
+                False,
+                SettingKind.STRING,
+                id="non-sensitive-string",
+            ),
+            pytest.param(
+                SettingDefinition(
+                    "test_setting",
+                    sensitive=False,
+                    kind=SettingKind.PASSWORD,
+                ),
+                True,
+                SettingKind.PASSWORD,
+                id="non-sensitive-password",
+            ),
+            pytest.param(
+                SettingDefinition(
+                    "test_setting",
+                    sensitive=False,
+                    kind=None,
+                ),
+                False,
+                None,
+                id="non-sensitive-no-kind",
+            ),
+            pytest.param(
+                SettingDefinition(
+                    "test_setting",
+                    sensitive=None,
+                    kind=SettingKind.STRING,
+                ),
+                None,
+                SettingKind.STRING,
+                id="no-sensitive-string",
+            ),
+            pytest.param(
+                SettingDefinition(
+                    "test_setting",
+                    sensitive=None,
+                    kind=SettingKind.PASSWORD,
+                ),
+                True,
+                SettingKind.PASSWORD,
+                id="no-sensitive-password",
+            ),
+            pytest.param(
+                SettingDefinition(
+                    "test_setting",
+                    sensitive=None,
+                    kind=None,
+                ),
+                None,
+                None,
+                id="no-sensitive-no-kind",
+            ),
+        ),
+    )
+    def test_parse(
+        self,
+        setting_definition: SettingDefinition,
+        sensitive: bool,
+        kind: SettingKind,
+    ):
+        assert setting_definition.sensitive is sensitive
+        assert setting_definition.kind is kind