Repository: meltano/sdk
Pull request #1603 (2023-04-12 00:38:37)

feat: Allow `allowed_values` and `examples` in any JSON schema type constructor
Docs updates:

- [`PropertiesList` usage example](https://meltano-sdk--1603.org.readthedocs.build/en/1603/typing.html#usage-example)
- [`StringType` with and without initialization](https://meltano-sdk--1603.org.readthedocs.build/en/1603/classes/typing/singer_sdk.typing.StringType.html)

<!-- readthedocs-preview meltano-sdk start -->
----
:books: Documentation preview :books:: https://meltano-sdk--1603.org.readthedocs.build/en/1603/

<!-- readthedocs-preview meltano-sdk end -->

codecov[bot]: ## [Codecov](https://codecov.io/gh/meltano/sdk/pull/1603?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) Report
> Merging [#1603](https://codecov.io/gh/meltano/sdk/pull/1603?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) (d672387) into [main](https://codecov.io/gh/meltano/sdk/commit/5ef52e24c63c09a917356ca7242c283983201256?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) (5ef52e2) will **increase** coverage by `0.06%`.
> The diff coverage is `100.00%`.

```diff
@@            Coverage Diff             @@
##             main    #1603      +/-   ##
==========================================
+ Coverage   86.18%   86.25%   +0.06%     
==========================================
  Files          58       58              
  Lines        4750     4771      +21     
  Branches      798      801       +3     
==========================================
+ Hits         4094     4115      +21     
  Misses        465      465              
  Partials      191      191              
```


| [Impacted Files](https://codecov.io/gh/meltano/sdk/pull/1603?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) | Coverage Δ | |
|---|---|---|
| [singer\_sdk/typing.py](https://codecov.io/gh/meltano/sdk/pull/1603?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano#diff-c2luZ2VyX3Nkay90eXBpbmcucHk=) | `95.34% <100.00%> (+0.50%)` | :arrow_up: |

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano)


Patch:
diff --git a/singer_sdk/typing.py b/singer_sdk/typing.py
index 3fe572ff..f73c8bd4 100644
--- a/singer_sdk/typing.py
+++ b/singer_sdk/typing.py
@@ -10,6 +10,15 @@ Usage example:
 
         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 @@ import json
 from typing import (
     TYPE_CHECKING,
     Any,
+    Callable,
     Generator,
     Generic,
     ItemsView,
@@ -62,7 +72,6 @@ from typing import (
 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 @@ _JsonValue: TypeAlias = Union[
     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 extend_validator_with_defaults(validator_class):  # noqa: ANN001, ANN201
     )
 
 
-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 @@ class JSONTypeHelper:
         """
         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 @@ class JSONTypeHelper:
         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 @@ class StringType(JSONTypeHelper):
         """
         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 @@ class ArrayType(JSONTypeHelper, Generic[W]):
         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 @@ class ObjectType(JSONTypeHelper):
         *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 @@ class ObjectType(JSONTypeHelper):
                 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 @@ class ObjectType(JSONTypeHelper):
         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 @@ class ObjectType(JSONTypeHelper):
             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


Test patch:
diff --git a/tests/core/test_jsonschema_helpers.py b/tests/core/test_jsonschema_helpers.py
index 6c262c41..34944205 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(