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

Add oneOf+const JSON Schema Option for Literals #9029

Closed
wants to merge 9 commits into from
2 changes: 2 additions & 0 deletions pydantic/_internal/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ class ConfigWrapper:
regex_engine: Literal['rust-regex', 'python-re']
validation_error_cause: bool
use_attribute_docstrings: bool
json_schema_literal_type: Literal['enum', 'oneof-const']
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd been following a great suggestion to look to the newly added opt-in attribute docstring support for inspiration here.

But I'm realizing that I followed that paradigm a bit too closely by putting the config option here, I think the best place for it is in a BaseModel.model_json_schema() parameter. Will refactor.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rmehyde,

Ah yeah, that makes sense re moving to model_json_schema. That being said, you should still be able to keep most / all of the docs / tests that you've written, which is nice. Ping me when you've refactored, and I'd be more than happy to review! Looks like great work so far :).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @sydney-runkle, I've made that update!

There's one check failing: a segfault when running the Ubuntu 3.10 tests. I don't believe this is directly related to my changes, and I don't get that behavior running the command in a 3.10 environment on my own Ubuntu machine. But I did re-run the CI/CD and the behavior is consistent, let me know what the right next step there is.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All good, that test is annoyingly flaky. I just reran it again, hopefully it passes this time 🍀

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At a first glance, looks great. I'll get back to you with a final confirmation tomorrow regarding if this is the best place to have this parameter. I think it is, but I suppose I could see an argument for just creating a custom instance of GenerateJsonSchema with this logic, and passing that to the model_json_schema() function.

Could you re-add that comprehensive docs section that you added? Maybe to the JSON schema docs (instead of where you originally had it, in the config docs)


def __init__(self, config: ConfigDict | dict[str, Any] | type[Any] | None, *, check: bool = True):
if check:
Expand Down Expand Up @@ -255,6 +256,7 @@ def push(self, config_wrapper: ConfigWrapper | ConfigDict | None):
regex_engine='rust-regex',
validation_error_cause=False,
use_attribute_docstrings=False,
json_schema_literal_type='enum',
)


Expand Down
8 changes: 7 additions & 1 deletion pydantic/_internal/_std_types_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,19 @@ def get_enum_core_schema(enum_type: type[Enum], config: ConfigDict) -> CoreSchem

enum_ref = get_type_ref(enum_type)
description = None if not enum_type.__doc__ else inspect.cleandoc(enum_type.__doc__)
case_descriptions = [
(c.value, inspect.cleandoc(c.__doc__))
for c in cases
if c.__doc__ is not None and inspect.cleandoc(c.__doc__) != description
]
if description == 'An enumeration.': # This is the default value provided by enum.EnumMeta.__new__; don't use it
description = None
updates = {'title': enum_type.__name__, 'description': description}
updates = {k: v for k, v in updates.items() if v is not None}

def get_json_schema(_, handler: GetJsonSchemaHandler) -> JsonSchemaValue:
json_schema = handler(core_schema.literal_schema([x.value for x in cases], ref=enum_ref))
metadata = {'enum_case_descriptions': case_descriptions}
json_schema = handler(core_schema.literal_schema([x.value for x in cases], ref=enum_ref, metadata=metadata))
original_schema = handler.resolve_ref_schema(json_schema)
update_json_schema(original_schema, updates)
return json_schema
Expand Down
62 changes: 62 additions & 0 deletions pydantic/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,68 @@ class Model(BaseModel):
can be different depending on the Python version used.
'''

json_schema_literal_type: Literal['enum', 'oneof-const']
"""
Whether to produce JSON Schema output of multiple Literal values as 'enum' or 'oneOf' with 'const' fields. Defaults
to 'enum'. For example, the given the following model:

```py
from enum import Enum

from pydantic import BaseModel

class FooBar(str, Enum):
foo = 'foo'
bar = 'bar'

class Model(BaseModel):
enum: FooBar
```

With the default `enum` value the JSON Schema will be:

```json
{
'title': 'Model',
'type': 'object',
'properties': {'enum': {'$ref': '#/$defs/FooBar'}},
'required': ['enum'],
'$defs': {
'FooBar': {
'title': 'FooBar',
'enum': ['foo', 'bar'],
'type': 'string',
}
},
}
```

With the 'oneof-const' value the JSON Schema will be:

```json
{
'title': 'Model',
'type': 'object',
'properties': {'enum': {'$ref': '#/$defs/FooBar'}},
'required': ['enum'],
'$defs': {
'FooBar': {
'title': 'FooBar',
'oneOf': [
{'const': 'foo'},
{'const': 'bar'},
],
'type': 'string',
}
},
}
```

Additionally, if a `__doc__` property is set on the attributes of an `Enum`, they will be used as descriptions in
the JSON schema `oneOf` members. For an example of setting `__doc__` on enum members, see
[this StackOverflow issue](https://stackoverflow.com/a/50473952).
"""


_TypeT = TypeVar('_TypeT', bound=type)

Expand Down
23 changes: 22 additions & 1 deletion pydantic/json_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -732,10 +732,31 @@ def literal_schema(self, schema: core_schema.LiteralSchema) -> JsonSchemaValue:
# jsonify the expected values
expected = [to_jsonable_python(v) for v in expected]

result: dict[str, Any] = {'enum': expected}
result: dict[str, Any] = {}
if len(expected) == 1:
result['const'] = expected[0]

if self._config.json_schema_literal_type == 'enum':
result['enum'] = expected
elif self._config.json_schema_literal_type == 'oneof-const':
# TODO (rmehyde): do we want this condition or not? why do we still produce 'enum' for single values?
if len(expected) > 1:
descriptions = schema.get('metadata', {}).get('enum_case_descriptions', [])
members = []
for e in expected:
member = {'const': e}

try:
description_idx = [d[0] for d in descriptions].index(e)
member['description'] = descriptions[description_idx][1]
except ValueError:
pass

members.append(member)
result['oneOf'] = members
else:
raise ValueError(f"Unknown literal type '{self._config.json_schema_literal_type}'")

types = {type(e) for e in expected}
if types == {str}:
result['type'] = 'string'
Expand Down
136 changes: 136 additions & 0 deletions tests/test_json_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,142 @@ class Model(BaseModel):
}


def test_enum_schema_oneof_const():
class FooBar(str, Enum):
"""
This enum Foos and Bars
"""

foo = 'foo'
bar = 'bar'

class Model(BaseModel):
model_config = ConfigDict(json_schema_literal_type='oneof-const')

enum: FooBar

assert Model.model_json_schema() == {
'title': 'Model',
'type': 'object',
'properties': {'enum': {'$ref': '#/$defs/FooBar'}},
'required': ['enum'],
'$defs': {
'FooBar': {
'title': 'FooBar',
'description': 'This enum Foos and Bars',
'oneOf': [
{'const': 'foo'},
{'const': 'bar'},
],
'type': 'string',
}
},
}


def test_enum_schema_oneof_const_member_docstring():
class DocumentedStrEnum(str, Enum):
"""
Courtesy of Ethan Furman: https://stackoverflow.com/a/50473952
"""

def __new__(cls, value, doc=None):
self = str.__new__(cls)
self._value_ = value
if doc is not None:
self.__doc__ = doc
return self

class FooBar(DocumentedStrEnum):
"""
This enum Foos and Bars
"""

foo = 'foo', 'this foos'
bar = 'bar', 'this bars'

class Model(BaseModel):
model_config = ConfigDict(json_schema_literal_type='oneof-const')

enum: FooBar

assert Model.model_json_schema() == {
'title': 'Model',
'type': 'object',
'properties': {'enum': {'$ref': '#/$defs/FooBar'}},
'required': ['enum'],
'$defs': {
'FooBar': {
'title': 'FooBar',
'description': 'This enum Foos and Bars',
'oneOf': [
{'const': 'foo', 'description': 'this foos'},
{'const': 'bar', 'description': 'this bars'},
],
'type': 'string',
}
},
}


def test_enum_schema_oneof_const_single_value():
class FooEnum(str, Enum):
"""
The Foo Enum
"""

foo = 'foo'

class Model(BaseModel):
model_config = ConfigDict(json_schema_literal_type='oneof-const')

enum: FooEnum

assert Model.model_json_schema() == {
'title': 'Model',
'type': 'object',
'properties': {'enum': {'$ref': '#/$defs/FooEnum'}},
'required': ['enum'],
'$defs': {
'FooEnum': {
'title': 'FooEnum',
'description': 'The Foo Enum',
'const': 'foo',
'type': 'string',
}
},
}


@pytest.mark.skipif(sys.version_info[:2] == (3, 8), reason="ListEnum doesn't work in 3.8")
def test_enum_schema_oneof_const_list_enum():
class ListEnum(List[int], Enum):
a = [123]
b = [456]

class Model(BaseModel):
model_config = ConfigDict(json_schema_literal_type='oneof-const')

enum: ListEnum

assert Model.model_json_schema() == {
'title': 'Model',
'type': 'object',
'properties': {'enum': {'$ref': '#/$defs/ListEnum'}},
'required': ['enum'],
'$defs': {
'ListEnum': {
'title': 'ListEnum',
'oneOf': [
{'const': [123]},
{'const': [456]},
],
'type': 'array',
}
},
}


def test_decimal_json_schema():
class Model(BaseModel):
a: bytes = b'foobar'
Expand Down