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: JSON schema keywords such as maxLength are now supported in StringType, IntegerType and NumberType JSON schema helpers #2241

Merged
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
3 changes: 3 additions & 0 deletions docs/_templates/class.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@
.. autoclass:: {{ name }}
:members:
:special-members: __init__, __call__
{%- if name in ('IntegerType', 'NumberType') %}
:inherited-members: JSONTypeHelper
{%- endif %}
3 changes: 2 additions & 1 deletion docs/classes/typing/singer_sdk.typing.IntegerType.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@

.. autoclass:: IntegerType
:members:
:special-members: __init__, __call__
:special-members: __init__, __call__
:inherited-members: JSONTypeHelper
3 changes: 2 additions & 1 deletion docs/classes/typing/singer_sdk.typing.NumberType.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@

.. autoclass:: NumberType
:members:
:special-members: __init__, __call__
:special-members: __init__, __call__
:inherited-members: JSONTypeHelper
11 changes: 11 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"sphinx.ext.napoleon",
"sphinx.ext.autosectionlabel",
"sphinx.ext.autosummary",
"sphinx.ext.extlinks",
"sphinx.ext.intersphinx",
"sphinx.ext.linkcode",
"sphinx_copybutton",
Expand Down Expand Up @@ -138,6 +139,16 @@
"porting.html": "guides/porting.html",
}

# -- Options for extlinks -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/extensions/extlinks.html

extlinks = {
"jsonschema": (
"https://json-schema.org/understanding-json-schema/reference/%s",
"%s",
),
}

# -- Options for intersphinx -----------------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#configuration
intersphinx_mapping = {
Expand Down
145 changes: 121 additions & 24 deletions singer_sdk/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,22 +266,46 @@ class StringType(JSONTypeHelper[str]):
{'type': ['string']}
>>> StringType(allowed_values=["a", "b"]).type_dict
{'type': ['string'], 'enum': ['a', 'b']}
>>> StringType(max_length=10).type_dict
{'type': ['string'], 'maxLength': 10}
"""

string_format: str | None = None
"""String format.

See the `formats built into the JSON Schema specification`_.
See the :jsonschema:`JSON Schema reference <string#built-in-formats>` for a list of
all the built-in formats.

Returns:
A string describing the format.

.. _`formats built into the JSON Schema specification`:
https://json-schema.org/understanding-json-schema/reference/string.html#built-in-formats
"""

def __init__(
self,
*,
min_length: int | None = None,
max_length: int | None = None,
pattern: str | None = None,
**kwargs: t.Any,
) -> None:
"""Initialize StringType.

Args:
min_length: Minimum length of the string. See the
:jsonschema:`JSON Schema reference <string#length>` for details.
max_length: Maximum length of the string. See the
:jsonschema:`JSON Schema reference <string#length>` for details.
pattern: A regular expression pattern that the string must match. See the
:jsonschema:`JSON Schema reference <string#regexp>` for details.
**kwargs: Additional keyword arguments to pass to the parent class.
"""
super().__init__(**kwargs)
self.min_length = min_length
self.max_length = max_length
self.pattern = pattern

@property
def _format(self) -> dict:
def _format(self) -> dict[str, t.Any]:
return {"format": self.string_format} if self.string_format else {}

@DefaultInstanceProperty
Expand All @@ -291,12 +315,23 @@ def type_dict(self) -> dict:
Returns:
A dictionary describing the type.
"""
return {
result = {
"type": ["string"],
**self._format,
**self.extras,
}

if self.max_length is not None:
result["maxLength"] = self.max_length

if self.min_length is not None:
result["minLength"] = self.min_length

if self.pattern is not None:
result["pattern"] = self.pattern

return result


class DateTimeType(StringType):
"""DateTime type.
Expand Down Expand Up @@ -423,7 +458,71 @@ def type_dict(self) -> dict:
return {"type": ["boolean"], **self.extras}


class IntegerType(JSONTypeHelper):
class _NumericType(JSONTypeHelper[T]):
"""Abstract numeric type for integers and numbers."""

__type_name__: str

def __init__(
self,
*,
minimum: int | None = None,
maximum: int | None = None,
exclusive_minimum: int | None = None,
exclusive_maximum: int | None = None,
multiple_of: int | None = None,
**kwargs: t.Any,
) -> None:
"""Initialize IntegerType.

Args:
minimum: Minimum numeric value. See the
:jsonschema:`JSON Schema reference <numeric#range>` for details.
maximum: Maximum numeric value.
:jsonschema:`JSON Schema reference <numeric#range>` for details.
exclusive_minimum: Exclusive minimum numeric value.
:jsonschema:`JSON Schema reference <numeric#range>` for details.
exclusive_maximum: Exclusive maximum numeric value. See the
:jsonschema:`JSON Schema reference <numeric#range>` for details.
multiple_of: A number that the value must be a multiple of. See the
:jsonschema:`JSON Schema reference <numeric#multiples>` for details.
**kwargs: Additional keyword arguments to pass to the parent class.
"""
super().__init__(**kwargs)
self.minimum = minimum
self.maximum = maximum
self.exclusive_minimum = exclusive_minimum
self.exclusive_maximum = exclusive_maximum
self.multiple_of = multiple_of

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

Returns:
A dictionary describing the type.
"""
result = {"type": [self.__type_name__], **self.extras}

if self.minimum is not None:
result["minimum"] = self.minimum

if self.maximum is not None:
result["maximum"] = self.maximum

if self.exclusive_minimum is not None:
result["exclusiveMinimum"] = self.exclusive_minimum

if self.exclusive_maximum is not None:
result["exclusiveMaximum"] = self.exclusive_maximum

if self.multiple_of is not None:
result["multipleOf"] = self.multiple_of

return result


class IntegerType(_NumericType[int]):
"""Integer type.

Examples:
Expand All @@ -433,19 +532,18 @@ class IntegerType(JSONTypeHelper):
{'type': ['integer']}
>>> IntegerType(allowed_values=[1, 2]).type_dict
{'type': ['integer'], 'enum': [1, 2]}
>>> IntegerType(minimum=0, maximum=10).type_dict
{'type': ['integer'], 'minimum': 0, 'maximum': 10}
>>> IntegerType(exclusive_minimum=0, exclusive_maximum=10).type_dict
{'type': ['integer'], 'exclusiveMinimum': 0, 'exclusiveMaximum': 10}
>>> IntegerType(multiple_of=2).type_dict
{'type': ['integer'], 'multipleOf': 2}
"""

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

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


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

Examples:
Expand All @@ -455,16 +553,15 @@ class NumberType(JSONTypeHelper[float]):
{'type': ['number']}
>>> NumberType(allowed_values=[1.0, 2.0]).type_dict
{'type': ['number'], 'enum': [1.0, 2.0]}
>>> NumberType(minimum=0, maximum=10).type_dict
{'type': ['number'], 'minimum': 0, 'maximum': 10}
>>> NumberType(exclusive_minimum=0, exclusive_maximum=10).type_dict
{'type': ['number'], 'exclusiveMinimum': 0, 'exclusiveMaximum': 10}
>>> NumberType(multiple_of=2).type_dict
{'type': ['number'], 'multipleOf': 2}
"""

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

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


W = t.TypeVar("W", bound=JSONTypeHelper)
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 @@ -502,6 +502,50 @@ def test_inbuilt_type(json_type: JSONTypeHelper, expected_json_schema: dict):
},
{is_array_type, is_string_array_type},
),
(
Property(
"my_prop12",
StringType(min_length=5, max_length=10, pattern="^a.*b$"),
),
{
"my_prop12": {
"type": ["string", "null"],
"minLength": 5,
"maxLength": 10,
"pattern": "^a.*b$",
},
},
{is_string_type},
),
(
Property(
"my_prop13",
IntegerType(minimum=5, maximum=10),
),
{
"my_prop13": {
"type": ["integer", "null"],
"minimum": 5,
"maximum": 10,
},
},
{is_integer_type},
),
(
Property(
"my_prop14",
IntegerType(exclusive_minimum=5, exclusive_maximum=10, multiple_of=2),
),
{
"my_prop14": {
"type": ["integer", "null"],
"exclusiveMinimum": 5,
"exclusiveMaximum": 10,
"multipleOf": 2,
},
},
{is_integer_type},
),
],
)
def test_property_creation(
Expand Down