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: Allow allowed_values and examples in any JSON schema type constructor #1603

Merged
merged 7 commits into from
Apr 13, 2023
Merged
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
186 changes: 150 additions & 36 deletions singer_sdk/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@

Property("id", IntegerType, required=True),
Property("foo_or_bar", StringType, allowed_values=["foo", "bar"]),
Property(
"permissions",
ArrayType(
StringType(
allowed_values=["create", "delete", "insert", "update"],
examples=["insert", "update"],
),
),
),
Property("ratio", NumberType, examples=[0.25, 0.75, 1.0]),
Property("days_active", IntegerType),
Property("updated_on", DateTimeType),
Expand Down Expand Up @@ -49,6 +58,7 @@
from typing import (
TYPE_CHECKING,
Any,
Callable,
Generator,
Generic,
ItemsView,
Expand All @@ -62,7 +72,6 @@
import sqlalchemy
from jsonschema import ValidationError, Validator, validators

from singer_sdk.helpers._classproperty import classproperty
from singer_sdk.helpers._typing import (
JSONSCHEMA_ANNOTATION_SECRET,
JSONSCHEMA_ANNOTATION_WRITEONLY,
Expand Down Expand Up @@ -120,6 +129,9 @@
None,
]

T = TypeVar("T", bound=_JsonValue)
P = TypeVar("P")


def extend_validator_with_defaults(validator_class): # noqa: ANN001, ANN201
"""Fill in defaults, before validating with the provided JSON Schema Validator.
Expand Down Expand Up @@ -159,25 +171,87 @@ def set_defaults(
)


class JSONTypeHelper:
class DefaultInstanceProperty:
"""Property of default instance.

Descriptor similar to ``property`` that decorates an instance method to retrieve
a property from the instance initialized with default parameters, if the called on
the class.
"""

def __init__(self, fget: Callable) -> None:
"""Initialize the decorator.

Args:
fget: The function to decorate.
"""
self.fget = fget

def __get__(self, instance: P, owner: type[P]) -> Any: # noqa: ANN401
"""Get the property value.

Args:
instance: The instance to get the property value from.
owner: The class to get the property value from.

Returns:
The property value.
"""
if instance is None:
instance = owner()
edgarrmondragon marked this conversation as resolved.
Show resolved Hide resolved
return self.fget(instance)


class JSONTypeHelper(Generic[T]):
"""Type helper base class for JSONSchema types."""

@classproperty
def type_dict(cls) -> dict: # noqa: N805
def __init__(
self,
*,
allowed_values: list[T] | None = None,
examples: list[T] | None = None,
) -> None:
"""Initialize the type helper.

Args:
allowed_values: A list of allowed values.
examples: A list of example values.
"""
self.allowed_values = allowed_values
self.examples = examples

@DefaultInstanceProperty
def type_dict(self) -> dict:
"""Return dict describing the type.

Raises:
NotImplementedError: If the derived class does not override this method.
"""
raise NotImplementedError

@property
def extras(self) -> dict:
"""Return dict describing the JSON Schema extras.

Returns:
A dictionary containing the JSON Schema extras.
"""
result = {}
if self.allowed_values:
result["enum"] = self.allowed_values

if self.examples:
result["examples"] = self.examples

return result

def to_dict(self) -> dict:
"""Convert to dictionary.

Returns:
A JSON Schema dictionary describing the object.
"""
return cast(dict, self.type_dict)
return self.type_dict # type: ignore[no-any-return]

def to_json(self, **kwargs: Any) -> str:
"""Convert to JSON.
Expand All @@ -191,8 +265,17 @@ def to_json(self, **kwargs: Any) -> str:
return json.dumps(self.to_dict(), **kwargs)


class StringType(JSONTypeHelper):
"""String type."""
class StringType(JSONTypeHelper[str]):
"""String type.

Examples:
>>> StringType.type_dict
{'type': ['string']}
>>> StringType().type_dict
{'type': ['string']}
>>> StringType(allowed_values=["a", "b"]).type_dict
{'type': ['string'], 'enum': ['a', 'b']}
"""

string_format: str | None = None
"""String format.
Expand All @@ -206,20 +289,21 @@ class StringType(JSONTypeHelper):
https://json-schema.org/understanding-json-schema/reference/string.html#built-in-formats
"""

@classproperty
def _format(cls) -> dict: # noqa: N805
return {"format": cls.string_format} if cls.string_format else {}
@property
def _format(self) -> dict:
return {"format": self.string_format} if self.string_format else {}

@classproperty
def type_dict(cls) -> dict: # noqa: N805
@DefaultInstanceProperty
def type_dict(self) -> dict:
"""Get type dictionary.

Returns:
A dictionary describing the type.
"""
return {
"type": ["string"],
**cls._format,
**self._format,
**self.extras,
}


Expand Down Expand Up @@ -328,58 +412,85 @@ class RegexType(StringType):
string_format = "regex"


class BooleanType(JSONTypeHelper):
"""Boolean type."""
class BooleanType(JSONTypeHelper[bool]):
"""Boolean type.

@classproperty
def type_dict(cls) -> dict: # noqa: N805
Examples:
>>> BooleanType.type_dict
{'type': ['boolean']}
>>> BooleanType().type_dict
{'type': ['boolean']}
"""

@DefaultInstanceProperty
def type_dict(self) -> dict:
"""Get type dictionary.

Returns:
A dictionary describing the type.
"""
return {"type": ["boolean"]}
return {"type": ["boolean"], **self.extras}


class IntegerType(JSONTypeHelper):
"""Integer type."""
"""Integer type.

Examples:
>>> IntegerType.type_dict
{'type': ['integer']}
>>> IntegerType().type_dict
{'type': ['integer']}
>>> IntegerType(allowed_values=[1, 2]).type_dict
{'type': ['integer'], 'enum': [1, 2]}
"""

@classproperty
def type_dict(cls) -> dict: # noqa: N805
@DefaultInstanceProperty
def type_dict(self) -> dict:
"""Get type dictionary.

Returns:
A dictionary describing the type.
"""
return {"type": ["integer"]}
return {"type": ["integer"], **self.extras}


class NumberType(JSONTypeHelper):
"""Number type."""
class NumberType(JSONTypeHelper[float]):
"""Number type.

Examples:
>>> NumberType.type_dict
{'type': ['number']}
>>> NumberType().type_dict
{'type': ['number']}
>>> NumberType(allowed_values=[1.0, 2.0]).type_dict
{'type': ['number'], 'enum': [1.0, 2.0]}
"""

@classproperty
def type_dict(cls) -> dict: # noqa: N805
@DefaultInstanceProperty
def type_dict(self) -> dict:
"""Get type dictionary.

Returns:
A dictionary describing the type.
"""
return {"type": ["number"]}
return {"type": ["number"], **self.extras}


W = TypeVar("W", bound=JSONTypeHelper)


class ArrayType(JSONTypeHelper, Generic[W]):
class ArrayType(JSONTypeHelper[list], Generic[W]):
"""Array type."""

def __init__(self, wrapped_type: W | type[W]) -> None:
def __init__(self, wrapped_type: W | type[W], **kwargs: Any) -> None:
"""Initialize Array type with wrapped inner type.

Args:
wrapped_type: JSON Schema item type inside the array.
**kwargs: Additional keyword arguments to pass to the parent class.
"""
self.wrapped_type = wrapped_type
super().__init__(**kwargs)

@property
def type_dict(self) -> dict: # type: ignore[override]
Expand All @@ -388,23 +499,23 @@ def type_dict(self) -> dict: # type: ignore[override]
Returns:
A dictionary describing the type.
"""
return {"type": "array", "items": self.wrapped_type.type_dict}
return {"type": "array", "items": self.wrapped_type.type_dict, **self.extras}


class Property(JSONTypeHelper, Generic[W]):
class Property(JSONTypeHelper[T], Generic[T]):
"""Generic Property. Should be nested within a `PropertiesList`."""

# TODO: Make some of these arguments keyword-only. This is a breaking change.
def __init__(
self,
name: str,
wrapped: W | type[W],
wrapped: JSONTypeHelper[T] | type[JSONTypeHelper[T]],
required: bool = False, # noqa: FBT001, FBT002
default: _JsonValue | None = None,
default: T | None = None,
description: str | None = None,
secret: bool | None = False, # noqa: FBT002
allowed_values: list[Any] | None = None,
examples: list[Any] | None = None,
allowed_values: list[T] | None = None,
examples: list[T] | None = None,
) -> None:
"""Initialize Property object.

Expand Down Expand Up @@ -491,6 +602,7 @@ def __init__(
*properties: Property,
additional_properties: W | type[W] | bool | None = None,
pattern_properties: Mapping[str, W | type[W]] | None = None,
**kwargs: Any,
) -> None:
"""Initialize ObjectType from its list of properties.

Expand All @@ -500,6 +612,7 @@ def __init__(
this object, or a boolean indicating if extra properties are allowed.
pattern_properties: A dictionary of regex patterns to match against
property names, and the schema to match against the values.
**kwargs: Additional keyword arguments to pass to the `JSONTypeHelper`.

Examples:
>>> t = ObjectType(
Expand Down Expand Up @@ -576,6 +689,7 @@ def __init__(
self.wrapped: dict[str, Property] = {prop.name: prop for prop in properties}
self.additional_properties = additional_properties
self.pattern_properties = pattern_properties
super().__init__(**kwargs)

@property
def type_dict(self) -> dict: # type: ignore[override]
Expand All @@ -590,7 +704,7 @@ def type_dict(self) -> dict: # type: ignore[override]
merged_props.update(w.to_dict())
if not w.optional:
required.append(w.name)
result: dict = {"type": "object", "properties": merged_props}
result: dict[str, Any] = {"type": "object", "properties": merged_props}

if required:
result["required"] = required
Expand Down
44 changes: 44 additions & 0 deletions tests/core/test_jsonschema_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,50 @@ def test_inbuilt_type(json_type: JSONTypeHelper, expected_json_schema: dict):
},
{is_integer_type},
),
(
Property(
WillDaSilva marked this conversation as resolved.
Show resolved Hide resolved
"my_prop10",
ArrayType(
StringType(
allowed_values=["create", "delete", "insert", "update"],
examples=["insert", "update"],
),
),
),
{
"my_prop10": {
"type": ["array", "null"],
"items": {
"type": ["string"],
"enum": ["create", "delete", "insert", "update"],
"examples": ["insert", "update"],
},
},
},
{is_array_type, is_string_array_type},
),
(
Property(
"my_prop11",
ArrayType(
StringType,
allowed_values=[
["create", "delete"],
["insert", "update"],
],
),
),
{
"my_prop11": {
"type": ["array", "null"],
"items": {
"type": ["string"],
},
"enum": [["create", "delete"], ["insert", "update"]],
},
},
{is_array_type, is_string_array_type},
),
],
)
def test_property_creation(
Expand Down