voluptuous-openapi 0.2.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voluptuous-openapi
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Convert voluptuous schemas to OpenAPI Schema object
5
5
  Author-email: Denis Shulyaka <Shulyaka@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -55,7 +55,7 @@ You can pass a custom serializer to be able to process custom validators. If the
55
55
 
56
56
  ```python
57
57
 
58
- from voluptuous_openai import UNSUPPORTED, convert
58
+ from voluptuous_openapi import UNSUPPORTED, convert
59
59
 
60
60
  def custom_convert(value):
61
61
  if value is my_custom_validator:
@@ -42,7 +42,7 @@ You can pass a custom serializer to be able to process custom validators. If the
42
42
 
43
43
  ```python
44
44
 
45
- from voluptuous_openai import UNSUPPORTED, convert
45
+ from voluptuous_openapi import UNSUPPORTED, convert
46
46
 
47
47
  def custom_convert(value):
48
48
  if value is my_custom_validator:
@@ -4,7 +4,7 @@ requires = ["setuptools>=77.0"]
4
4
 
5
5
  [project]
6
6
  name = "voluptuous-openapi"
7
- version = "0.2.0"
7
+ version = "0.4.0"
8
8
  license = "Apache-2.0"
9
9
  description = "Convert voluptuous schemas to OpenAPI Schema object"
10
10
  readme = "README.md"
@@ -410,6 +410,11 @@ def test_key_any():
410
410
  },
411
411
  "required": [],
412
412
  "type": "object",
413
+ "anyOf": [
414
+ {"required": ["hours"]},
415
+ {"required": ["minutes"]},
416
+ {"required": ["seconds"]},
417
+ ],
413
418
  } == convert(
414
419
  {
415
420
  vol.Required(vol.Any("hours", "minutes", "seconds")): int,
@@ -830,3 +835,483 @@ def test_convert_to_voluptuous_nullable_number_with_range():
830
835
 
831
836
  with pytest.raises(vol.Invalid):
832
837
  validator(101.0) # too high
838
+
839
+
840
+ TEST_TASK_ITEM = {"content": "a task ", "description": "a description"}
841
+ TASK_SCHEMA = {
842
+ "type": "object",
843
+ "properties": {
844
+ "tasks": {
845
+ "type": "array",
846
+ "items": {
847
+ "type": "object",
848
+ "properties": {
849
+ "content": {
850
+ "type": "string",
851
+ "description": "The content of the task to create.",
852
+ },
853
+ "description": {
854
+ "type": "string",
855
+ "description": "The description of the task.",
856
+ },
857
+ },
858
+ "required": ["content"],
859
+ "additionalProperties": False,
860
+ },
861
+ }
862
+ },
863
+ }
864
+
865
+
866
+ @pytest.mark.parametrize(
867
+ "extra_tasks_data, input_data",
868
+ [
869
+ ({"minItems": 1, "maxItems": 2}, {"tasks": [TEST_TASK_ITEM]}),
870
+ ({"minItems": 1, "maxItems": 2}, {"tasks": [TEST_TASK_ITEM, TEST_TASK_ITEM]}),
871
+ ({"minItems": 1}, {"tasks": [TEST_TASK_ITEM]}),
872
+ ({"maxItems": 2}, {"tasks": [TEST_TASK_ITEM, TEST_TASK_ITEM]}),
873
+ ],
874
+ )
875
+ def test_with_min_max_items_success(extra_tasks_data, input_data):
876
+ task_schema = TASK_SCHEMA.copy()
877
+ task_schema["properties"]["tasks"].update(extra_tasks_data)
878
+
879
+ validator = convert_to_voluptuous(task_schema)
880
+
881
+ validator(input_data)
882
+
883
+
884
+ @pytest.mark.parametrize(
885
+ "extra_tasks_data, input_data",
886
+ [
887
+ ({"minItems": 1, "maxItems": 2}, {"tasks": []}),
888
+ (
889
+ {"minItems": 1, "maxItems": 2},
890
+ {"tasks": [TEST_TASK_ITEM, TEST_TASK_ITEM, TEST_TASK_ITEM]},
891
+ ),
892
+ ({"minItems": 1}, {"tasks": []}),
893
+ ({"maxItems": 2}, {"tasks": [TEST_TASK_ITEM, TEST_TASK_ITEM, TEST_TASK_ITEM]}),
894
+ ],
895
+ )
896
+ def test_with_min_max_items_fails_validation(extra_tasks_data, input_data):
897
+ task_schema = TASK_SCHEMA.copy()
898
+ task_schema["properties"]["tasks"].update(extra_tasks_data)
899
+
900
+ validator = convert_to_voluptuous(task_schema)
901
+
902
+ with pytest.raises(vol.Invalid):
903
+ validator(input_data)
904
+
905
+
906
+ def test_required_any_of():
907
+ """Test schemas with Required(Any(...)) constraints."""
908
+ assert {
909
+ "properties": {
910
+ "color": {"type": "string"},
911
+ "temperature": {"type": "integer"},
912
+ "brightness": {"type": "integer"},
913
+ },
914
+ "required": [],
915
+ "type": "object",
916
+ "anyOf": [
917
+ {"required": ["color"]},
918
+ {"required": ["temperature"]},
919
+ {"required": ["brightness"]},
920
+ ],
921
+ } == convert(
922
+ {
923
+ vol.Required(vol.Any("color", "temperature", "brightness")): object,
924
+ vol.Optional("color"): str,
925
+ vol.Optional("temperature"): int,
926
+ vol.Optional("brightness"): int,
927
+ }
928
+ )
929
+
930
+ assert {
931
+ "properties": {
932
+ "color": {"type": "string"},
933
+ "temperature": {"type": "integer"},
934
+ "brightness": {"type": "integer"},
935
+ "mode": {"type": "string"},
936
+ "preset": {"type": "string"},
937
+ },
938
+ "required": [],
939
+ "type": "object",
940
+ "anyOf": [
941
+ {"required": ["color", "mode"]},
942
+ {"required": ["color", "preset"]},
943
+ {"required": ["temperature", "mode"]},
944
+ {"required": ["temperature", "preset"]},
945
+ {"required": ["brightness", "mode"]},
946
+ {"required": ["brightness", "preset"]},
947
+ ],
948
+ } == convert(
949
+ {
950
+ vol.Required(vol.Any("color", "temperature", "brightness")): object,
951
+ vol.Required(vol.Any("mode", "preset")): str,
952
+ vol.Optional("color"): str,
953
+ vol.Optional("temperature"): int,
954
+ vol.Optional("brightness"): int,
955
+ vol.Optional("mode"): str,
956
+ vol.Optional("preset"): str,
957
+ }
958
+ )
959
+
960
+ assert {
961
+ "properties": {
962
+ "entity_id": {"type": "string"},
963
+ "color": {"type": "string"},
964
+ "temperature": {"type": "integer"},
965
+ },
966
+ "required": ["entity_id"],
967
+ "type": "object",
968
+ "anyOf": [{"required": ["color"]}, {"required": ["temperature"]}],
969
+ } == convert(
970
+ {
971
+ vol.Required("entity_id"): str,
972
+ vol.Required(vol.Any("color", "temperature")): str,
973
+ vol.Optional("color"): str,
974
+ vol.Optional("temperature"): int,
975
+ }
976
+ )
977
+
978
+
979
+ def test_required_any_of_description():
980
+ """Test that the description is preserved in a Required(Any(...)) constraint."""
981
+ assert {
982
+ "properties": {
983
+ "color": {"type": "string", "description": "Light appearance"},
984
+ "temperature": {"type": "string", "description": "Light appearance"},
985
+ },
986
+ "required": [],
987
+ "type": "object",
988
+ "anyOf": [{"required": ["color"]}, {"required": ["temperature"]}],
989
+ } == convert(
990
+ {
991
+ vol.Required(
992
+ vol.Any("color", "temperature"), description="Light appearance"
993
+ ): str,
994
+ }
995
+ )
996
+
997
+
998
+ def test_required_any_of_inner_description():
999
+ """Test that inner descriptions are preferred in a Required(Any(...)) constraint."""
1000
+ assert {
1001
+ "properties": {
1002
+ "color": {"type": "string", "description": "Inner color description"},
1003
+ "temperature": {"type": "string", "description": "Outer description"},
1004
+ },
1005
+ "required": [],
1006
+ "type": "object",
1007
+ "anyOf": [{"required": ["color"]}, {"required": ["temperature"]}],
1008
+ } == convert(
1009
+ {
1010
+ vol.Required(
1011
+ vol.Any(
1012
+ vol.Optional("color", description="Inner color description"),
1013
+ "temperature",
1014
+ ),
1015
+ description="Outer description",
1016
+ ): str,
1017
+ }
1018
+ )
1019
+
1020
+
1021
+ def test_simple_ref_defs() -> None:
1022
+ """Test basic JSON pointer reference resolution using the $defs keyword."""
1023
+ schema = {
1024
+ "$defs": {"MyInt": {"type": "integer"}},
1025
+ "type": "object",
1026
+ "properties": {"age": {"$ref": "#/$defs/MyInt"}},
1027
+ }
1028
+ validator = convert_to_voluptuous(schema)
1029
+ # Check it validates valid input
1030
+ res = validator({"age": 42})
1031
+ assert res == {"age": 42}
1032
+
1033
+ # Check it raises vol.Invalid on invalid input
1034
+ with pytest.raises(vol.Invalid):
1035
+ validator({"age": "hello"})
1036
+
1037
+
1038
+ def test_definitions_compatibility() -> None:
1039
+ """Test compatibility with older JSON Schema drafts using definitions instead of $defs."""
1040
+ schema = {
1041
+ "definitions": {"MyInt": {"type": "integer"}},
1042
+ "type": "object",
1043
+ "properties": {"age": {"$ref": "#/definitions/MyInt"}},
1044
+ }
1045
+ validator = convert_to_voluptuous(schema)
1046
+ res = validator({"age": 42})
1047
+ assert res == {"age": 42}
1048
+
1049
+ with pytest.raises(vol.Invalid):
1050
+ validator({"age": "not-an-int"})
1051
+
1052
+
1053
+ def test_chained_refs() -> None:
1054
+ """Test chained references where one reference points to another reference."""
1055
+ schema = {
1056
+ "$defs": {"Level2": {"type": "integer"}, "Level1": {"$ref": "#/$defs/Level2"}},
1057
+ "type": "object",
1058
+ "properties": {"num": {"$ref": "#/$defs/Level1"}},
1059
+ }
1060
+ validator = convert_to_voluptuous(schema)
1061
+ res = validator({"num": 42})
1062
+ assert res == {"num": 42}
1063
+
1064
+ with pytest.raises(vol.Invalid):
1065
+ validator({"num": "yes"})
1066
+
1067
+
1068
+ def test_root_self_ref_valid() -> None:
1069
+ """Test root self-reference (#) with valid recursively nested structures."""
1070
+ schema = {"type": "object", "properties": {"child": {"$ref": "#"}}}
1071
+ validator = convert_to_voluptuous(schema)
1072
+
1073
+ # Single level
1074
+ assert validator({"child": {}}) == {"child": {}}
1075
+
1076
+ # Nested level
1077
+ assert validator({"child": {"child": {"child": {}}}}) == {
1078
+ "child": {"child": {"child": {}}}
1079
+ }
1080
+
1081
+
1082
+ def test_root_self_ref_invalid() -> None:
1083
+ """Test root self-reference (#) with invalid type values."""
1084
+ schema = {"type": "object", "properties": {"child": {"$ref": "#"}}}
1085
+ validator = convert_to_voluptuous(schema)
1086
+
1087
+ with pytest.raises(vol.Invalid):
1088
+ validator({"child": "not-an-object"})
1089
+
1090
+
1091
+ def test_recursive_tree_valid() -> None:
1092
+ """Test recursive tree node reference validation with valid structures."""
1093
+ schema = {
1094
+ "$defs": {
1095
+ "Node": {
1096
+ "type": "object",
1097
+ "properties": {
1098
+ "value": {"type": "string"},
1099
+ "children": {"type": "array", "items": {"$ref": "#/$defs/Node"}},
1100
+ },
1101
+ "required": ["value"],
1102
+ }
1103
+ },
1104
+ "$ref": "#/$defs/Node",
1105
+ }
1106
+ validator = convert_to_voluptuous(schema)
1107
+
1108
+ # Leaf node
1109
+ assert validator({"value": "leaf"}) == {"value": "leaf"}
1110
+
1111
+ # Deep recursive tree
1112
+ tree = {
1113
+ "value": "root",
1114
+ "children": [
1115
+ {"value": "child1", "children": [{"value": "grandchild1"}]},
1116
+ {"value": "child2"},
1117
+ ],
1118
+ }
1119
+ assert validator(tree) == tree
1120
+
1121
+
1122
+ def test_recursive_tree_invalid() -> None:
1123
+ """Test recursive tree node reference validation with invalid type values."""
1124
+ schema = {
1125
+ "$defs": {
1126
+ "Node": {
1127
+ "type": "object",
1128
+ "properties": {
1129
+ "value": {"type": "string"},
1130
+ "children": {"type": "array", "items": {"$ref": "#/$defs/Node"}},
1131
+ },
1132
+ "required": ["value"],
1133
+ }
1134
+ },
1135
+ "$ref": "#/$defs/Node",
1136
+ }
1137
+ validator = convert_to_voluptuous(schema)
1138
+
1139
+ invalid_tree = {"value": "root", "children": [{"value": 123}]} # Should be string
1140
+ with pytest.raises(vol.Invalid):
1141
+ validator(invalid_tree)
1142
+
1143
+
1144
+ def test_anonymized_field_definition_valid() -> None:
1145
+ """Test anonymized FieldDefinition schema with valid primitive and complex structures."""
1146
+ schema = {
1147
+ "$defs": {
1148
+ "FieldDefinition": {
1149
+ "type": "object",
1150
+ "properties": {
1151
+ "type": {
1152
+ "type": "string",
1153
+ "enum": ["BOOLEAN", "STRING", "INTEGER", "STRUCT", "LIST"],
1154
+ },
1155
+ "structFields": {
1156
+ "type": "object",
1157
+ "additionalProperties": {"$ref": "#/$defs/FieldDefinition"},
1158
+ },
1159
+ "listElement": {"$ref": "#/$defs/FieldDefinition"},
1160
+ },
1161
+ "required": ["type"],
1162
+ }
1163
+ },
1164
+ "$ref": "#/$defs/FieldDefinition",
1165
+ }
1166
+ validator = convert_to_voluptuous(schema)
1167
+
1168
+ # Simple integer type definition
1169
+ assert validator({"type": "INTEGER"}) == {"type": "INTEGER"}
1170
+
1171
+ # Complex struct containing other field definitions (including a list element)
1172
+ complex_def = {
1173
+ "type": "STRUCT",
1174
+ "structFields": {
1175
+ "name": {"type": "STRING"},
1176
+ "tags": {"type": "LIST", "listElement": {"type": "STRING"}},
1177
+ },
1178
+ }
1179
+ assert validator(complex_def) == complex_def
1180
+
1181
+
1182
+ def test_anonymized_field_definition_invalid() -> None:
1183
+ """Test anonymized FieldDefinition schema with invalid enum values and recursive types."""
1184
+ schema = {
1185
+ "$defs": {
1186
+ "FieldDefinition": {
1187
+ "type": "object",
1188
+ "properties": {
1189
+ "type": {
1190
+ "type": "string",
1191
+ "enum": ["BOOLEAN", "STRING", "INTEGER", "STRUCT", "LIST"],
1192
+ },
1193
+ "structFields": {
1194
+ "type": "object",
1195
+ "additionalProperties": {"$ref": "#/$defs/FieldDefinition"},
1196
+ },
1197
+ "listElement": {"$ref": "#/$defs/FieldDefinition"},
1198
+ },
1199
+ "required": ["type"],
1200
+ }
1201
+ },
1202
+ "$ref": "#/$defs/FieldDefinition",
1203
+ }
1204
+ validator = convert_to_voluptuous(schema)
1205
+
1206
+ # Fails if enum value is wrong
1207
+ with pytest.raises(vol.Invalid):
1208
+ validator({"type": "FLOAT"})
1209
+
1210
+ # Fails if recursive validation fails
1211
+ with pytest.raises(vol.Invalid):
1212
+ validator(
1213
+ {
1214
+ "type": "STRUCT",
1215
+ "structFields": {"invalid_field": {"type": 123}}, # type must be string
1216
+ }
1217
+ )
1218
+
1219
+
1220
+ def test_anonymized_ghp_home_automation_valid() -> None:
1221
+ """Test anonymized GhpHomeAutomation schema with valid automation rules."""
1222
+ schema = {
1223
+ "$defs": {
1224
+ "Comparison": {
1225
+ "type": "object",
1226
+ "properties": {
1227
+ "fieldToCompare": {"type": "string"},
1228
+ "operator": {"type": "string", "enum": ["EQUALS", "LESS_THAN"]},
1229
+ "nestedComparison": {"$ref": "#/$defs/Comparison"},
1230
+ "operands": {"type": "array", "items": {"$ref": "#/$defs/Operand"}},
1231
+ },
1232
+ },
1233
+ "Operand": {
1234
+ "type": "object",
1235
+ "properties": {
1236
+ "comparison": {"$ref": "#/$defs/Comparison"},
1237
+ "value": {"type": "string"},
1238
+ },
1239
+ },
1240
+ "ConditionBlock": {
1241
+ "type": "object",
1242
+ "properties": {
1243
+ "comparison": {"$ref": "#/$defs/Comparison"},
1244
+ "description": {"type": "string"},
1245
+ },
1246
+ },
1247
+ },
1248
+ "type": "object",
1249
+ "properties": {
1250
+ "conditions": {"type": "array", "items": {"$ref": "#/$defs/ConditionBlock"}}
1251
+ },
1252
+ }
1253
+ validator = convert_to_voluptuous(schema)
1254
+
1255
+ valid_automation = {
1256
+ "conditions": [
1257
+ {
1258
+ "description": "Check temperature",
1259
+ "comparison": {
1260
+ "fieldToCompare": "temp",
1261
+ "operator": "LESS_THAN",
1262
+ "operands": [{"value": "72"}],
1263
+ },
1264
+ }
1265
+ ]
1266
+ }
1267
+ assert validator(valid_automation) == valid_automation
1268
+
1269
+
1270
+ def test_anonymized_ghp_home_automation_invalid() -> None:
1271
+ """Test anonymized GhpHomeAutomation schema with invalid operator constraints."""
1272
+ schema = {
1273
+ "$defs": {
1274
+ "Comparison": {
1275
+ "type": "object",
1276
+ "properties": {
1277
+ "fieldToCompare": {"type": "string"},
1278
+ "operator": {"type": "string", "enum": ["EQUALS", "LESS_THAN"]},
1279
+ "nestedComparison": {"$ref": "#/$defs/Comparison"},
1280
+ "operands": {"type": "array", "items": {"$ref": "#/$defs/Operand"}},
1281
+ },
1282
+ },
1283
+ "Operand": {
1284
+ "type": "object",
1285
+ "properties": {
1286
+ "comparison": {"$ref": "#/$defs/Comparison"},
1287
+ "value": {"type": "string"},
1288
+ },
1289
+ },
1290
+ "ConditionBlock": {
1291
+ "type": "object",
1292
+ "properties": {
1293
+ "comparison": {"$ref": "#/$defs/Comparison"},
1294
+ "description": {"type": "string"},
1295
+ },
1296
+ },
1297
+ },
1298
+ "type": "object",
1299
+ "properties": {
1300
+ "conditions": {"type": "array", "items": {"$ref": "#/$defs/ConditionBlock"}}
1301
+ },
1302
+ }
1303
+ validator = convert_to_voluptuous(schema)
1304
+
1305
+ # Invalid operator in nested comparison
1306
+ invalid_automation = {
1307
+ "conditions": [
1308
+ {
1309
+ "comparison": {
1310
+ "fieldToCompare": "temp",
1311
+ "operator": "GREATER_THAN", # Not in comparison enum ["EQUALS", "LESS_THAN"]
1312
+ }
1313
+ }
1314
+ ]
1315
+ }
1316
+ with pytest.raises(vol.Invalid):
1317
+ validator(invalid_automation)
@@ -576,3 +576,70 @@ def test_maybe(validator: Validator) -> None:
576
576
 
577
577
  with pytest.raises(InvalidFormat):
578
578
  validator({"key": "value"})
579
+
580
+
581
+ @pytest.mark.parametrize(
582
+ "validator",
583
+ generate_validators(
584
+ {
585
+ "type": "object",
586
+ "properties": {
587
+ "color": {"type": "string"},
588
+ "temperature": {"type": "integer"},
589
+ "brightness": {
590
+ "type": "integer",
591
+ "minimum": 0,
592
+ "maximum": 100,
593
+ },
594
+ },
595
+ "anyOf": [
596
+ {"required": ["color"]},
597
+ {"required": ["temperature"]},
598
+ {"required": ["brightness"]},
599
+ ],
600
+ },
601
+ vol.Schema(
602
+ {
603
+ vol.Required(vol.Any("color", "temperature", "brightness")): object,
604
+ vol.Optional("color"): str,
605
+ vol.Optional("temperature"): int,
606
+ vol.Optional("brightness"): vol.All(int, vol.Range(min=0, max=100)),
607
+ }
608
+ ),
609
+ ),
610
+ ids=TEST_IDS,
611
+ )
612
+ def test_any_of_constraint(validator: Validator) -> None:
613
+ """Test anyOf constraint for requiring at least one of multiple properties."""
614
+ # Test valid cases
615
+ validator({"color": "red"})
616
+ validator({"temperature": 20})
617
+ validator({"brightness": 80})
618
+ validator({"brightness": 0})
619
+ validator({"brightness": 100})
620
+ validator({"color": "blue", "temperature": 25})
621
+ validator({"color": "green", "brightness": 100})
622
+ validator({"temperature": 22, "brightness": 90})
623
+ validator({"color": "purple", "temperature": 21, "brightness": 70})
624
+
625
+ # Test invalid cases
626
+ with pytest.raises(InvalidFormat):
627
+ validator({}) # Missing all required properties
628
+
629
+ with pytest.raises(InvalidFormat):
630
+ validator({"other_field": "value"}) # Missing all required properties
631
+
632
+ with pytest.raises(InvalidFormat):
633
+ validator({"brightness": -1}) # Out of range
634
+
635
+ with pytest.raises(InvalidFormat):
636
+ validator({"brightness": 101}) # Out of range
637
+
638
+ with pytest.raises(InvalidFormat):
639
+ validator({"brightness": "abc"}) # Wrong type
640
+
641
+ with pytest.raises(InvalidFormat):
642
+ validator({"color": 123}) # Wrong type
643
+
644
+ with pytest.raises(InvalidFormat):
645
+ validator({"temperature": "abc"}) # Wrong type
@@ -3,6 +3,7 @@
3
3
  from collections.abc import Callable, Mapping, Sequence
4
4
  from inspect import signature
5
5
  from enum import Enum, StrEnum
6
+ import itertools
6
7
  import re
7
8
  from typing import Any, TypeVar, Union, get_args, get_origin, get_type_hints
8
9
  from types import NoneType, UnionType
@@ -24,11 +25,12 @@ UNSUPPORTED = object()
24
25
  OPENAPI_UNSUPPORTED_KEYWORDS = {
25
26
  "allOf",
26
27
  "multipleOf",
27
- "minItems",
28
- "maxItems",
29
28
  "uniqueItems",
30
29
  }
31
30
 
31
+ # The standard JSON Schema reference keyword
32
+ JSON_SCHEMA_REF = "$ref"
33
+
32
34
 
33
35
  class OpenApiVersion(StrEnum):
34
36
  """The OpenAPI version.
@@ -88,6 +90,9 @@ def convert(
88
90
  if isinstance(schema, Mapping):
89
91
  properties = {}
90
92
  required = []
93
+ any_of_constraint_groups: list[list[str]] = (
94
+ []
95
+ ) # List of lists, each containing candidate keys for a Required(Any(...))
91
96
 
92
97
  for key, value in schema.items():
93
98
  description = None
@@ -101,22 +106,57 @@ def convert(
101
106
  if description:
102
107
  pval["description"] = key.description
103
108
 
104
- if isinstance(key, (vol.Required, vol.Optional)):
109
+ if isinstance(key, vol.Required):
110
+ if not isinstance(key.schema, vol.Any):
111
+ required.append(str(key.schema))
112
+ if key.default is not vol.UNDEFINED:
113
+ pval["default"] = key.default()
114
+ elif isinstance(key, vol.Optional):
105
115
  if key.default is not vol.UNDEFINED:
106
116
  pval["default"] = key.default()
107
117
 
108
118
  pval = ensure_default(pval)
109
119
 
110
120
  if isinstance(pkey, vol.Any):
111
- for val in pkey.validators:
112
- if isinstance(val, vol.Marker):
113
- if val.description:
114
- properties[str(val.schema)] = pval.copy()
115
- properties[str(val.schema)]["description"] = val.description
121
+ # Handle Required(Any(...)) pattern for anyOf constraints
122
+ if isinstance(key, vol.Required):
123
+ # Extract candidate keys and their descriptions from Any validator
124
+ candidate_items = []
125
+ for val_item in pkey.validators:
126
+ item_key = ""
127
+ item_desc = None
128
+ if isinstance(val_item, vol.Marker):
129
+ item_key = str(val_item.schema)
130
+ item_desc = val_item.description
116
131
  else:
117
- properties[str(val)] = pval
118
- else:
119
- properties[str(val)] = pval
132
+ item_key = str(val_item)
133
+ candidate_items.append({"key": item_key, "desc": item_desc})
134
+
135
+ candidate_keys = [item["key"] for item in candidate_items]
136
+ any_of_constraint_groups.append(candidate_keys)
137
+
138
+ # If the value is not a wildcard, create properties for each
139
+ # candidate key, preserving any descriptions.
140
+ if value is not object:
141
+ for item in candidate_items:
142
+ prop_schema = pval.copy()
143
+ final_description = item["desc"] or description
144
+ if final_description:
145
+ prop_schema["description"] = final_description
146
+ properties[item["key"]] = prop_schema
147
+ else:
148
+ # Handle Optional(Any(...)) - expand to individual properties
149
+ for val_item in pkey.validators:
150
+ if isinstance(val_item, vol.Marker):
151
+ if val_item.description:
152
+ properties[str(val_item.schema)] = pval.copy()
153
+ properties[str(val_item.schema)][
154
+ "description"
155
+ ] = val_item.description
156
+ else:
157
+ properties[str(val_item)] = pval
158
+ else:
159
+ properties[str(val_item)] = pval
120
160
  elif isinstance(pkey, str):
121
161
  properties[pkey] = pval
122
162
  else:
@@ -126,15 +166,21 @@ def convert(
126
166
  if additional_properties is None:
127
167
  additional_properties = pval
128
168
 
129
- if isinstance(key, vol.Required) and not isinstance(pkey, vol.Any):
130
- required.append(str(pkey))
131
-
132
169
  val = {"type": "object"}
133
170
  if properties or not additional_properties:
134
171
  val["properties"] = properties
135
172
  val["required"] = required
136
173
  if additional_properties:
137
174
  val["additionalProperties"] = additional_properties
175
+
176
+ # Generate anyOf constraints from the Cartesian product of constraint groups
177
+ if any_of_constraint_groups:
178
+ # Generate all combinations (Cartesian product) of the constraint groups
179
+ val["anyOf"] = [
180
+ {"required": list(combination)}
181
+ for combination in itertools.product(*any_of_constraint_groups)
182
+ ]
183
+
138
184
  return val
139
185
 
140
186
  if isinstance(schema, vol.All):
@@ -406,12 +452,112 @@ def convert(
406
452
  raise ValueError("Unable to convert schema: {}".format(schema))
407
453
 
408
454
 
409
- def convert_to_voluptuous(schema: dict) -> vol.Schema:
410
- """Convert an OpenAPI Schema object to a voluptuous schema."""
455
+ def resolve_ref(ref: str, root_schema: dict) -> dict:
456
+ """Resolve a JSON pointer reference within the root_schema.
457
+
458
+ A JSON pointer (defined in RFC 6901) is a string syntax for identifying a
459
+ specific value within a JSON document. It uses '/' to separate keys or list
460
+ indices. This function resolves local pointer references starting with '#'.
461
+
462
+ Example:
463
+ Given root_schema = {"$defs": {"User": {"type": "object"}}},
464
+ resolve_ref("#/$defs/User", root_schema) returns {"type": "object"}.
465
+ """
466
+ if not ref.startswith("#"):
467
+ raise ValueError(f"External/relative $ref not supported: {ref}")
468
+ if ref == "#" or ref == "":
469
+ return root_schema
470
+
471
+ parts = ref.lstrip("#").split("/")
472
+ # If the ref was '#/something', lstrip('#') is '/something', and split('/')
473
+ # produces ['', 'something']. We pop the first empty element.
474
+ if parts[0] == "":
475
+ parts.pop(0)
476
+
477
+ current = root_schema
478
+ for part in parts:
479
+ # RFC 6901 specifies '~1' represents '/' and '~0' represents '~'
480
+ part = part.replace("~1", "/").replace("~0", "~")
481
+ if isinstance(current, dict) and part in current:
482
+ current = current[part]
483
+ elif isinstance(current, list):
484
+ try:
485
+ idx = int(part)
486
+ current = current[idx]
487
+ except (ValueError, IndexError):
488
+ raise ValueError(f"Could not resolve ref {ref} in list at index {part}")
489
+ else:
490
+ raise ValueError(f"Could not resolve ref {ref} at key {part}")
491
+
492
+ return current
493
+
494
+
495
+ class LazySchema:
496
+ """A wrapper validator that resolves and compiles a schema reference lazily.
497
+
498
+ This leverages voluptuous's ability to use any Python callable as a validator.
499
+ By returning a LazySchema instance during compilation, we avoid infinite
500
+ recursion/loops for circular or self-referential schemas. The schema is
501
+ resolved and compiled only on its first invocation, and cached for future runs.
502
+ """
503
+
504
+ def __init__(self, ref: str, root_schema: dict):
505
+ self.ref = ref
506
+ self.root_schema = root_schema
507
+ self._compiled_schema = None
508
+
509
+ def __call__(self, value: Any) -> Any:
510
+ """Validate the value against the lazily compiled schema.
511
+
512
+ On the first validation call, this resolves the JSON pointer reference
513
+ and compiles it into a voluptuous validator. Subsequent validation calls
514
+ bypass the compilation and reuse the cached validator.
515
+ """
516
+ if self._compiled_schema is None:
517
+ target_schema = resolve_ref(self.ref, self.root_schema)
518
+ self._compiled_schema = convert_to_voluptuous(
519
+ target_schema, self.root_schema
520
+ )
521
+ return self._compiled_schema(value)
522
+
523
+ def __eq__(self, other: Any) -> bool:
524
+ if not isinstance(other, LazySchema):
525
+ return False
526
+ return self.ref == other.ref and self.root_schema == other.root_schema
527
+
528
+ def __repr__(self) -> str:
529
+ return f"LazySchema({self.ref!r})"
530
+
531
+
532
+ def convert_to_voluptuous(schema: dict, root_schema: dict | None = None) -> Any:
533
+ """Convert an OpenAPI/JSON Schema object to a voluptuous schema.
534
+
535
+ Args:
536
+ schema: The OpenAPI/JSON Schema dictionary to convert.
537
+ root_schema: The root schema document, which is propagated recursively
538
+ to resolve local JSON pointer references ($ref). If not provided,
539
+ defaults to the current schema.
540
+
541
+ Returns:
542
+ A voluptuous Schema, a type validator, or a custom validator callable.
543
+ """
411
544
 
412
545
  if not isinstance(schema, dict):
413
546
  raise ValueError("Invalid schema, expected a dictionary")
414
547
 
548
+ if root_schema is None:
549
+ root_schema = schema
550
+
551
+ if JSON_SCHEMA_REF in schema:
552
+ return LazySchema(schema[JSON_SCHEMA_REF], root_schema)
553
+
554
+ if (enum_values := schema.get("enum")) is not None:
555
+ if not isinstance(enum_values, list):
556
+ raise ValueError("Invalid schema, enum should be a list")
557
+ if schema.get("nullable") is True:
558
+ return vol.Any(vol.In(enum_values), None)
559
+ return vol.In(enum_values)
560
+
415
561
  for keyword in OPENAPI_UNSUPPORTED_KEYWORDS:
416
562
  if keyword in schema:
417
563
  raise ValueError(f"{keyword} is not supported")
@@ -421,12 +567,26 @@ def convert_to_voluptuous(schema: dict) -> vol.Schema:
421
567
  raise ValueError("Invalid schema, oneOf should be a list")
422
568
  # This implements anyOf semantics sice it matches any of the subschemas,
423
569
  # not just one of them.
424
- return vol.Any(*[convert_to_voluptuous(sub_schema) for sub_schema in one_of])
570
+ return vol.Any(
571
+ *[convert_to_voluptuous(sub_schema, root_schema) for sub_schema in one_of]
572
+ )
425
573
 
426
574
  if (any_of := schema.get("anyOf")) is not None:
427
575
  if not isinstance(any_of, list):
428
576
  raise ValueError("Invalid schema, anyOf should be a list")
429
- return vol.Any(*[convert_to_voluptuous(sub_schema) for sub_schema in any_of])
577
+ # If the anyOf is a list of required properties, it's a constraint on an
578
+ # object and should be handled by the object validator.
579
+ is_required_constraint = all(
580
+ list(s.keys()) == ["required"] and isinstance(s["required"], list)
581
+ for s in any_of
582
+ )
583
+ if not (is_required_constraint and schema.get("type") == "object"):
584
+ return vol.Any(
585
+ *[
586
+ convert_to_voluptuous(sub_schema, root_schema)
587
+ for sub_schema in any_of
588
+ ]
589
+ )
430
590
 
431
591
  if (schema_type := schema.get("type")) is None:
432
592
  raise ValueError("Invalid schema, missing type")
@@ -446,7 +606,7 @@ def convert_to_voluptuous(schema: dict) -> vol.Schema:
446
606
  else:
447
607
  base_schema = schema.copy()
448
608
  base_schema["type"] = t
449
- validators.append(convert_to_voluptuous(base_schema))
609
+ validators.append(convert_to_voluptuous(base_schema, root_schema))
450
610
  return vol.Any(*validators)
451
611
 
452
612
  if (basic_type := TYPES_MAP_REV.get(schema_type)) is not None:
@@ -481,7 +641,7 @@ def convert_to_voluptuous(schema: dict) -> vol.Schema:
481
641
  properties = {}
482
642
  required_properties = set(schema.get("required", []))
483
643
  for key, value in schema.get("properties", {}).items():
484
- value_type = convert_to_voluptuous(value)
644
+ value_type = convert_to_voluptuous(value, root_schema)
485
645
  description: str | None = None
486
646
  if isinstance(value, dict):
487
647
  description = value.get("description")
@@ -491,9 +651,19 @@ def convert_to_voluptuous(schema: dict) -> vol.Schema:
491
651
  key_type = vol.Optional
492
652
  properties[key_type(key, description=description)] = value_type
493
653
 
494
- validator = None
495
- if schema.get("additionalProperties") is True:
654
+ if (any_of := schema.get("anyOf")) is not None:
655
+ any_of_keys = [
656
+ key for sub_schema in any_of for key in sub_schema.get("required", [])
657
+ ]
658
+ if any_of_keys:
659
+ properties[vol.Required(vol.Any(*any_of_keys))] = object
660
+
661
+ extra_schema = schema.get("additionalProperties")
662
+ if extra_schema is True:
496
663
  validator = vol.Schema(properties, extra=vol.ALLOW_EXTRA)
664
+ elif isinstance(extra_schema, dict):
665
+ properties[vol.Extra] = convert_to_voluptuous(extra_schema, root_schema)
666
+ validator = vol.Schema(properties)
497
667
  else:
498
668
  validator = vol.Schema(properties)
499
669
 
@@ -504,7 +674,16 @@ def convert_to_voluptuous(schema: dict) -> vol.Schema:
504
674
  return validator
505
675
 
506
676
  if schema_type == "array":
507
- validator = vol.Schema([convert_to_voluptuous(schema["items"])])
677
+ item_validator = convert_to_voluptuous(schema["items"], root_schema)
678
+ min_items = schema.get("minItems")
679
+ max_items = schema.get("maxItems")
680
+
681
+ if min_items is not None or max_items is not None:
682
+ validator = vol.Schema(
683
+ vol.All([item_validator], vol.Length(min=min_items, max=max_items))
684
+ )
685
+ else:
686
+ validator = vol.Schema([item_validator])
508
687
 
509
688
  # Handle OpenAPI 3.0 nullable property
510
689
  if schema.get("nullable") is True:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voluptuous-openapi
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Convert voluptuous schemas to OpenAPI Schema object
5
5
  Author-email: Denis Shulyaka <Shulyaka@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -55,7 +55,7 @@ You can pass a custom serializer to be able to process custom validators. If the
55
55
 
56
56
  ```python
57
57
 
58
- from voluptuous_openai import UNSUPPORTED, convert
58
+ from voluptuous_openapi import UNSUPPORTED, convert
59
59
 
60
60
  def custom_convert(value):
61
61
  if value is my_custom_validator: