Repository: meltano/sdk
Pull request #2789 (2024-12-01 00:09:27)

feat: Conditionally required settings
## Feature Docs

- [`singer_sdk.typing.PropertiesList`](https://meltano-sdk--2789.org.readthedocs.build/en/2789/classes/typing/singer_sdk.typing.PropertiesList.html#singer_sdk.typing.PropertiesList)
- [`singer_sdk.typing.Property`](https://meltano-sdk--2789.org.readthedocs.build/en/2789/classes/typing/singer_sdk.typing.Property.html)

## Use Cases
- matatika/tap-capsulecrm has both access token and OAuth flows:

  - https://github.com/Matatika/tap-capsulecrm/blob/b7798cea5209369ac1e8b8e4645c26ea82d8cc90/tap_capsulecrm/tap.py#L24-L30
  - https://github.com/meltano/hub/blob/5c5228872a27767b18e22dc78f92386065a72500/_data/meltano/extractors/tap-capsulecrm/matatika.yml#L92-L96

## Related

- Closes https://github.com/meltano/sdk/issues/1519
- https://github.com/meltano/meltano/issues/8003

## References

- [JSON Schema: Applying Subschemas Conditionally](https://json-schema.org/understanding-json-schema/reference/conditionals)

<!-- readthedocs-preview meltano-sdk start -->
----
📚 Documentation preview 📚: https://meltano-sdk--2789.org.readthedocs.build/en/2789/

<!-- readthedocs-preview meltano-sdk end -->

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/meltano/sdk/pull/2789?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) Report
All modified and coverable lines are covered by tests :white_check_mark:
> 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 [(`13b77e0`)](https://app.codecov.io/gh/meltano/sdk/commit/13b77e0f309d04cd4b967d730f4c28d0cd896e1e?dropdown=coverage&el=desc&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    #2789      +/-   ##
==========================================
+ Coverage   91.02%   91.03%   +0.01%     
==========================================
  Files          62       62              
  Lines        5170     5177       +7     
  Branches      666      668       +2     
==========================================
+ Hits         4706     4713       +7     
  Misses        326      326              
  Partials      138      138              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/meltano/sdk/pull/2789?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).

codspeed-hq[bot]: ## [CodSpeed Performance Report](https://codspeed.io/meltano/sdk/branches/1519-feat-conditionally-required-settings)

### Merging #2789 will **not alter performance**

<sub>Comparing <code>1519-feat-conditionally-required-settings</code> (13b77e0) with <code>main</code> (a553c38)</sub>



### Summary

`✅ 6` untouched benchmarks  





Patch:
diff --git a/singer_sdk/typing.py b/singer_sdk/typing.py
index 2a3a1472..d810d940 100644
--- a/singer_sdk/typing.py
+++ b/singer_sdk/typing.py
@@ -656,6 +656,7 @@ class Property(JSONTypeHelper[T], t.Generic[T]):
         nullable: bool | None = None,
         title: str | None = None,
         deprecated: bool | None = None,
+        requires_properties: list[str] | None = None,
         **kwargs: t.Any,
     ) -> None:
         """Initialize Property object.
@@ -680,6 +681,8 @@ class Property(JSONTypeHelper[T], t.Generic[T]):
             nullable: If True, the property may be null.
             title: Optional. A short, human-readable title for the property.
             deprecated: If True, mark this property as deprecated.
+            requires_properties: A list of property names that must also be present if
+                this property is present.
             **kwargs: Additional keyword arguments to pass to the parent class.
         """
         self.name = name
@@ -693,6 +696,7 @@ class Property(JSONTypeHelper[T], t.Generic[T]):
         self.nullable = nullable
         self.title = title
         self.deprecated = deprecated
+        self.requires_properties = requires_properties
         self.kwargs = kwargs
 
     @property
@@ -866,10 +870,14 @@ class ObjectType(JSONTypeHelper):
         """
         merged_props = {}
         required = []
+        dependent_required: dict[str, list[str]] = {}
         for w in self.wrapped.values():
             merged_props.update(w.to_dict())
             if not w.optional:
                 required.append(w.name)
+            if w.requires_properties:
+                dependent_required[w.name] = w.requires_properties
+
         result: dict[str, t.Any] = {
             "type": ["object", "null"] if self.nullable else "object",
             "properties": merged_props,
@@ -878,6 +886,9 @@ class ObjectType(JSONTypeHelper):
         if required:
             result["required"] = required
 
+        if dependent_required:
+            result["dependentRequired"] = dependent_required
+
         if self.additional_properties is not None:
             if isinstance(self.additional_properties, bool):
                 result["additionalProperties"] = self.additional_properties
@@ -1115,7 +1126,75 @@ class CustomType(JSONTypeHelper):
 
 
 class PropertiesList(ObjectType):
-    """Properties list. A convenience wrapper around the ObjectType class."""
+    """Properties list. A convenience wrapper around the ObjectType class.
+
+    Examples:
+        >>> schema = PropertiesList(
+        ...     # username/password
+        ...     Property("username", StringType, requires_properties=["password"]),
+        ...     Property("password", StringType, secret=True),
+        ...     # OAuth
+        ...     Property(
+        ...         "client_id",
+        ...         StringType,
+        ...         requires_properties=["client_secret", "refresh_token"],
+        ...     ),
+        ...     Property("client_secret", StringType, secret=True),
+        ...     Property("refresh_token", StringType, secret=True),
+        ... )
+        >>> print(schema.to_json(indent=2))
+        {
+          "type": "object",
+          "properties": {
+            "username": {
+              "type": [
+                "string",
+                "null"
+              ]
+            },
+            "password": {
+              "type": [
+                "string",
+                "null"
+              ],
+              "secret": true,
+              "writeOnly": true
+            },
+            "client_id": {
+              "type": [
+                "string",
+                "null"
+              ]
+            },
+            "client_secret": {
+              "type": [
+                "string",
+                "null"
+              ],
+              "secret": true,
+              "writeOnly": true
+            },
+            "refresh_token": {
+              "type": [
+                "string",
+                "null"
+              ],
+              "secret": true,
+              "writeOnly": true
+            }
+          },
+          "dependentRequired": {
+            "username": [
+              "password"
+            ],
+            "client_id": [
+              "client_secret",
+              "refresh_token"
+            ]
+          },
+          "$schema": "https://json-schema.org/draft/2020-12/schema"
+        }
+    """
 
     def items(self) -> t.ItemsView[str, Property]:
         """Get wrapped properties.


Test patch:
diff --git a/tests/core/test_jsonschema_helpers.py b/tests/core/test_jsonschema_helpers.py
index dbaf89a6..cae9f843 100644
--- a/tests/core/test_jsonschema_helpers.py
+++ b/tests/core/test_jsonschema_helpers.py
@@ -977,6 +977,52 @@ def test_discriminated_union():
     )
 
 
+def test_schema_dependencies():
+    th = ObjectType(
+        # username/password
+        Property("username", StringType, requires_properties=["password"]),
+        Property("password", StringType, secret=True),
+        # OAuth
+        Property(
+            "client_id",
+            StringType,
+            requires_properties=["client_secret", "refresh_token"],
+        ),
+        Property("client_secret", StringType, secret=True),
+        Property("refresh_token", StringType, secret=True),
+    )
+
+    validator = DEFAULT_JSONSCHEMA_VALIDATOR(th.to_dict())
+
+    assert validator.is_valid(
+        {
+            "username": "foo",
+            "password": "bar",
+        },
+    )
+
+    assert validator.is_valid(
+        {
+            "client_id": "foo",
+            "client_secret": "bar",
+            "refresh_token": "baz",
+        },
+    )
+
+    assert not validator.is_valid(
+        {
+            "username": "foo",
+        },
+    )
+
+    assert not validator.is_valid(
+        {
+            "client_id": "foo",
+            "client_secret": "bar",
+        },
+    )
+
+
 def test_is_datetime_type():
     assert is_datetime_type({"type": "string", "format": "date-time"})
     assert not is_datetime_type({"type": "string"})