Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Conditionally required settings #2789

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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