Skip to content

Commit

Permalink
feat: Conditionally required settings
Browse files Browse the repository at this point in the history
  • Loading branch information
edgarrmondragon committed Nov 30, 2024
1 parent a553c38 commit 13b77e0
Show file tree
Hide file tree
Showing 2 changed files with 132 additions and 1 deletion.
87 changes: 86 additions & 1 deletion singer_sdk/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,7 @@ def __init__( # noqa: PLR0913
*,
nullable: bool | None = None,
title: str | None = None,
requires_properties: str | list[str] | None = None,
) -> None:
"""Initialize Property object.
Expand All @@ -671,6 +672,8 @@ def __init__( # noqa: PLR0913
displayed to the user as hints of the expected format of inputs.
nullable: If True, the property may be null.
title: Optional. A short, human-readable title for the property.
requires_properties: A list of property names that must be present if this
property is present.
"""
self.name = name
self.wrapped = wrapped
Expand All @@ -682,6 +685,7 @@ def __init__( # noqa: PLR0913
self.examples = examples or None
self.nullable = nullable
self.title = title
self.requires_properties = requires_properties

@property
def type_dict(self) -> dict: # type: ignore[override]
Expand Down Expand Up @@ -848,10 +852,20 @@ def type_dict(self) -> dict: # type: ignore[override]
"""
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:
# Convert single string to list for consistent handling
required_props = (
[w.requires_properties]
if isinstance(w.requires_properties, str)
else w.requires_properties
)
dependent_required[w.name] = required_props

result: dict[str, t.Any] = {
"type": ["object", "null"] if self.nullable else "object",
"properties": merged_props,
Expand All @@ -860,6 +874,9 @@ def type_dict(self) -> dict: # type: ignore[override]
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
Expand Down Expand Up @@ -1097,7 +1114,75 @@ def type_dict(self) -> dict: # type: ignore[override]


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.
Expand Down
46 changes: 46 additions & 0 deletions tests/core/test_jsonschema_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,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"})
Expand Down

0 comments on commit 13b77e0

Please sign in to comment.