diff --git a/singer_sdk/typing.py b/singer_sdk/typing.py index 3fe572ffa..f73c8bd4f 100644 --- a/singer_sdk/typing.py +++ b/singer_sdk/typing.py @@ -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), @@ -49,6 +58,7 @@ from typing import ( TYPE_CHECKING, Any, + Callable, Generator, Generic, ItemsView, @@ -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, @@ -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. @@ -159,11 +171,57 @@ 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() + 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: @@ -171,13 +229,29 @@ def type_dict(cls) -> dict: # noqa: N805 """ 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. @@ -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. @@ -206,12 +289,12 @@ 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: @@ -219,7 +302,8 @@ def type_dict(cls) -> dict: # noqa: N805 """ return { "type": ["string"], - **cls._format, + **self._format, + **self.extras, } @@ -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] @@ -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. @@ -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. @@ -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( @@ -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] @@ -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 diff --git a/tests/core/test_jsonschema_helpers.py b/tests/core/test_jsonschema_helpers.py index 6c262c412..34944205d 100644 --- a/tests/core/test_jsonschema_helpers.py +++ b/tests/core/test_jsonschema_helpers.py @@ -434,6 +434,50 @@ def test_inbuilt_type(json_type: JSONTypeHelper, expected_json_schema: dict): }, {is_integer_type}, ), + ( + Property( + "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(