From 842e9f3fe30b0ec9aae0827adeb0eabe03e3167d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Rami=CC=81rez=20Mondrago=CC=81n?= Date: Thu, 23 Mar 2023 22:18:06 -0600 Subject: [PATCH 1/4] feat: Support union schemas --- .../typing/singer_sdk.typing.Constant.rst | 8 ++ .../singer_sdk.typing.Discriminator.rst | 8 ++ .../typing/singer_sdk.typing.OneOf.rst | 8 ++ docs/reference.rst | 3 + singer_sdk/typing.py | 113 ++++++++++++++++++ tests/core/test_jsonschema_helpers.py | 47 ++++++++ 6 files changed, 187 insertions(+) create mode 100644 docs/classes/typing/singer_sdk.typing.Constant.rst create mode 100644 docs/classes/typing/singer_sdk.typing.Discriminator.rst create mode 100644 docs/classes/typing/singer_sdk.typing.OneOf.rst diff --git a/docs/classes/typing/singer_sdk.typing.Constant.rst b/docs/classes/typing/singer_sdk.typing.Constant.rst new file mode 100644 index 000000000..248f7eb57 --- /dev/null +++ b/docs/classes/typing/singer_sdk.typing.Constant.rst @@ -0,0 +1,8 @@ +singer_sdk.typing.Constant +========================== + +.. currentmodule:: singer_sdk.typing + +.. autoclass:: Constant + :members: + :special-members: __init__, __call__ \ No newline at end of file diff --git a/docs/classes/typing/singer_sdk.typing.Discriminator.rst b/docs/classes/typing/singer_sdk.typing.Discriminator.rst new file mode 100644 index 000000000..3e97d73c1 --- /dev/null +++ b/docs/classes/typing/singer_sdk.typing.Discriminator.rst @@ -0,0 +1,8 @@ +singer_sdk.typing.Discriminator +=============================== + +.. currentmodule:: singer_sdk.typing + +.. autoclass:: Discriminator + :members: + :special-members: __init__, __call__ \ No newline at end of file diff --git a/docs/classes/typing/singer_sdk.typing.OneOf.rst b/docs/classes/typing/singer_sdk.typing.OneOf.rst new file mode 100644 index 000000000..e9f159fe9 --- /dev/null +++ b/docs/classes/typing/singer_sdk.typing.OneOf.rst @@ -0,0 +1,8 @@ +singer_sdk.typing.OneOf +======================= + +.. currentmodule:: singer_sdk.typing + +.. autoclass:: OneOf + :members: + :special-members: __init__, __call__ \ No newline at end of file diff --git a/docs/reference.rst b/docs/reference.rst index 276a96d80..98f97a7e6 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -92,9 +92,11 @@ JSON Schema builder classes typing.PropertiesList typing.ArrayType typing.BooleanType + typing.Constant typing.CustomType typing.DateTimeType typing.DateType + typing.Discriminator typing.DurationType typing.EmailType typing.HostnameType @@ -104,6 +106,7 @@ JSON Schema builder classes typing.JSONPointerType typing.NumberType typing.ObjectType + typing.OneOf typing.Property typing.RegexType typing.RelativeJSONPointerType diff --git a/singer_sdk/typing.py b/singer_sdk/typing.py index fe837ef7f..05cf09c60 100644 --- a/singer_sdk/typing.py +++ b/singer_sdk/typing.py @@ -608,6 +608,119 @@ def type_dict(self) -> dict: # type: ignore # OK: @classproperty vs @property return result +class OneOf(JSONPointerType): + """OneOf type.""" + + def __init__(self, *types: W | type[W]) -> None: + """Initialize OneOf type. + + Args: + types: Types to choose from. + """ + self.wrapped = types + + @property + def type_dict(self) -> dict: # type: ignore[override] + """Get type dictionary. + + Returns: + A dictionary describing the type. + """ + return {"oneOf": [t.type_dict for t in self.wrapped]} + + +class Constant(JSONTypeHelper): + """A constant property.""" + + def __init__(self, value: _JsonValue) -> None: + """Initialize Constant. + + Args: + value: Value of the constant. + """ + self.value = value + + @property + def type_dict(self) -> dict: # type: ignore[override] + """Get type dictionary. + + Returns: + A dictionary describing the type. + """ + return {"const": self.value} + + +class Discriminator(OneOf): + """A discriminator property. + + This is a special case of :class:`singer_sdk.typing.OneOf`, where values are + JSON objects, and the type of the object is determined by a property in the + object. + + The property is a :class:`singer_sdk.typing.Constant` called the discriminator + property. + """ + + def __init__(self, key: str, **options: ObjectType) -> None: + """Initialize Discriminator. + + Args: + key: Name of the discriminator property. + options: Mapping of discriminator values to object types. + + Examples: + >>> t = Discriminator("species", cat=ObjectType(), dog=ObjectType()) + >>> print(t.to_json(indent=2)) + { + "oneOf": [ + { + "type": "object", + "properties": { + "species": { + "const": "cat", + "description": "Discriminator for object of type 'cat'." + } + }, + "required": [ + "species" + ] + }, + { + "type": "object", + "properties": { + "species": { + "const": "dog", + "description": "Discriminator for object of type 'dog'." + } + }, + "required": [ + "species" + ] + } + ] + } + """ + self.key = key + self.options = options + + super().__init__( + *( + ObjectType( + Property( + key, + Constant(k), + required=True, + description=f"Discriminator for object of type '{k}'.", + ), + *v.wrapped.values(), + additional_properties=v.additional_properties, + pattern_properties=v.pattern_properties, + ) + for k, v in options.items() + ), + ) + + class CustomType(JSONTypeHelper): """Accepts an arbitrary JSON Schema dictionary.""" diff --git a/tests/core/test_jsonschema_helpers.py b/tests/core/test_jsonschema_helpers.py index 6a4ef0ba6..4f5fe6cba 100644 --- a/tests/core/test_jsonschema_helpers.py +++ b/tests/core/test_jsonschema_helpers.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Callable import pytest +from jsonschema import Draft6Validator from singer_sdk.helpers._typing import ( JSONSCHEMA_ANNOTATION_SECRET, @@ -30,6 +31,7 @@ CustomType, DateTimeType, DateType, + Discriminator, DurationType, EmailType, HostnameType, @@ -757,3 +759,48 @@ def test_type_check_variations(property_schemas, type_check_functions, results): for property_schema in property_schemas: for type_check_function, result in zip(type_check_functions, results): assert type_check_function(property_schema) == result + + +def test_one_of_discrimination(): + th = Discriminator( + "flow", + oauth=ObjectType( + Property("client_id", StringType, required=True, secret=True), + Property("client_secret", StringType, required=True, secret=True), + additional_properties=False, + ), + password=ObjectType( + Property("username", StringType, required=True), + Property("password", StringType, required=True, secret=True), + additional_properties=False, + ), + ) + + validator = Draft6Validator(th.to_dict()) + + assert validator.is_valid( + { + "flow": "oauth", + "client_id": "123", + "client_secret": "456", + }, + ) + assert validator.is_valid( + { + "flow": "password", + "password": "123", + "username": "456", + }, + ) + assert not validator.is_valid( + { + "flow": "oauth", + "client_id": "123", + }, + ) + assert not validator.is_valid( + { + "flow": "password", + "client_id": "123", + }, + ) From 4b90dee298b5161e16abaebc44fb2612be5b826c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Rami=CC=81rez=20Mondrago=CC=81n?= Date: Fri, 24 Mar 2023 13:50:07 -0600 Subject: [PATCH 2/4] Rename `Discriminator` to `DiscriminatedUnion` --- singer_sdk/typing.py | 6 +++--- tests/core/test_jsonschema_helpers.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/singer_sdk/typing.py b/singer_sdk/typing.py index 05cf09c60..fbddf25ff 100644 --- a/singer_sdk/typing.py +++ b/singer_sdk/typing.py @@ -650,7 +650,7 @@ def type_dict(self) -> dict: # type: ignore[override] return {"const": self.value} -class Discriminator(OneOf): +class DiscriminatedUnion(OneOf): """A discriminator property. This is a special case of :class:`singer_sdk.typing.OneOf`, where values are @@ -662,14 +662,14 @@ class Discriminator(OneOf): """ def __init__(self, key: str, **options: ObjectType) -> None: - """Initialize Discriminator. + """Initialize a discriminated union type. Args: key: Name of the discriminator property. options: Mapping of discriminator values to object types. Examples: - >>> t = Discriminator("species", cat=ObjectType(), dog=ObjectType()) + >>> t = DiscriminatedUnion("species", cat=ObjectType(), dog=ObjectType()) >>> print(t.to_json(indent=2)) { "oneOf": [ diff --git a/tests/core/test_jsonschema_helpers.py b/tests/core/test_jsonschema_helpers.py index 83f844e2b..02186a828 100644 --- a/tests/core/test_jsonschema_helpers.py +++ b/tests/core/test_jsonschema_helpers.py @@ -31,7 +31,7 @@ CustomType, DateTimeType, DateType, - Discriminator, + DiscriminatedUnion, DurationType, EmailType, HostnameType, @@ -773,8 +773,8 @@ def test_type_check_variations(property_schemas, type_check_functions, results): assert type_check_function(property_schema) == result -def test_one_of_discrimination(): - th = Discriminator( +def test_discriminated_union(): + th = DiscriminatedUnion( "flow", oauth=ObjectType( Property("client_id", StringType, required=True, secret=True), From 55b99e7f7c75e4a9413d76d74765b9249fa72ed0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Rami=CC=81rez=20Mondrago=CC=81n?= Date: Fri, 24 Mar 2023 13:58:50 -0600 Subject: [PATCH 3/4] Update docs --- .../typing/singer_sdk.typing.DiscriminatedUnion.rst | 8 ++++++++ docs/classes/typing/singer_sdk.typing.Discriminator.rst | 8 -------- docs/reference.rst | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) create mode 100644 docs/classes/typing/singer_sdk.typing.DiscriminatedUnion.rst delete mode 100644 docs/classes/typing/singer_sdk.typing.Discriminator.rst diff --git a/docs/classes/typing/singer_sdk.typing.DiscriminatedUnion.rst b/docs/classes/typing/singer_sdk.typing.DiscriminatedUnion.rst new file mode 100644 index 000000000..132e2ca0a --- /dev/null +++ b/docs/classes/typing/singer_sdk.typing.DiscriminatedUnion.rst @@ -0,0 +1,8 @@ +singer_sdk.typing.DiscriminatedUnion +==================================== + +.. currentmodule:: singer_sdk.typing + +.. autoclass:: DiscriminatedUnion + :members: + :special-members: __init__, __call__ \ No newline at end of file diff --git a/docs/classes/typing/singer_sdk.typing.Discriminator.rst b/docs/classes/typing/singer_sdk.typing.Discriminator.rst deleted file mode 100644 index 3e97d73c1..000000000 --- a/docs/classes/typing/singer_sdk.typing.Discriminator.rst +++ /dev/null @@ -1,8 +0,0 @@ -singer_sdk.typing.Discriminator -=============================== - -.. currentmodule:: singer_sdk.typing - -.. autoclass:: Discriminator - :members: - :special-members: __init__, __call__ \ No newline at end of file diff --git a/docs/reference.rst b/docs/reference.rst index 98f97a7e6..84b145ada 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -96,7 +96,7 @@ JSON Schema builder classes typing.CustomType typing.DateTimeType typing.DateType - typing.Discriminator + typing.DiscriminatedUnion typing.DurationType typing.EmailType typing.HostnameType From 03658f5b988b46601b85d843c6c14150c1faf906 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Rami=CC=81rez=20Mondrago=CC=81n?= Date: Fri, 14 Apr 2023 17:04:28 -0600 Subject: [PATCH 4/4] Add more docstrings --- poetry.lock | 6 +++--- singer_sdk/typing.py | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 420ee53c6..51ed176f2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand. [[package]] name = "alabaster" @@ -2334,7 +2334,7 @@ files = [ ] [package.dependencies] -greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"} +greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and platform_machine == \"aarch64\" or python_version >= \"3\" and platform_machine == \"ppc64le\" or python_version >= \"3\" and platform_machine == \"x86_64\" or python_version >= \"3\" and platform_machine == \"amd64\" or python_version >= \"3\" and platform_machine == \"AMD64\" or python_version >= \"3\" and platform_machine == \"win32\" or python_version >= \"3\" and platform_machine == \"WIN32\""} importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} [package.extras] @@ -2687,7 +2687,7 @@ docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] [extras] -docs = ["sphinx", "furo", "sphinx-copybutton", "myst-parser", "sphinx-autobuild", "sphinx-reredirects"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-autobuild", "sphinx-copybutton", "sphinx-reredirects"] s3 = ["fs-s3fs"] testing = ["pytest", "pytest-durations"] diff --git a/singer_sdk/typing.py b/singer_sdk/typing.py index 9ba47968b..a2c061c2f 100644 --- a/singer_sdk/typing.py +++ b/singer_sdk/typing.py @@ -724,7 +724,28 @@ def type_dict(self) -> dict: # type: ignore[override] class OneOf(JSONPointerType): - """OneOf type.""" + """OneOf type. + + This type allows for a value to be one of a set of types. + + Examples: + >>> t = OneOf(StringType, IntegerType) + >>> print(t.to_json(indent=2)) + { + "oneOf": [ + { + "type": [ + "string" + ] + }, + { + "type": [ + "integer" + ] + } + ] + } + """ def __init__(self, *types: W | type[W]) -> None: """Initialize OneOf type. @@ -745,7 +766,17 @@ def type_dict(self) -> dict: # type: ignore[override] class Constant(JSONTypeHelper): - """A constant property.""" + """A constant property. + + A property that is always the same value. + + Examples: + >>> t = Constant("foo") + >>> print(t.to_json(indent=2)) + { + "const": "foo" + } + """ def __init__(self, value: _JsonValue) -> None: """Initialize Constant.