voluptuous-openapi 0.2.0__tar.gz → 0.3.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.3.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
@@ -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.3.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,184 @@ 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
+ )
@@ -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,8 +25,6 @@ UNSUPPORTED = object()
24
25
  OPENAPI_UNSUPPORTED_KEYWORDS = {
25
26
  "allOf",
26
27
  "multipleOf",
27
- "minItems",
28
- "maxItems",
29
28
  "uniqueItems",
30
29
  }
31
30
 
@@ -88,6 +87,9 @@ def convert(
88
87
  if isinstance(schema, Mapping):
89
88
  properties = {}
90
89
  required = []
90
+ any_of_constraint_groups: list[list[str]] = (
91
+ []
92
+ ) # List of lists, each containing candidate keys for a Required(Any(...))
91
93
 
92
94
  for key, value in schema.items():
93
95
  description = None
@@ -101,22 +103,57 @@ def convert(
101
103
  if description:
102
104
  pval["description"] = key.description
103
105
 
104
- if isinstance(key, (vol.Required, vol.Optional)):
106
+ if isinstance(key, vol.Required):
107
+ if not isinstance(key.schema, vol.Any):
108
+ required.append(str(key.schema))
109
+ if key.default is not vol.UNDEFINED:
110
+ pval["default"] = key.default()
111
+ elif isinstance(key, vol.Optional):
105
112
  if key.default is not vol.UNDEFINED:
106
113
  pval["default"] = key.default()
107
114
 
108
115
  pval = ensure_default(pval)
109
116
 
110
117
  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
118
+ # Handle Required(Any(...)) pattern for anyOf constraints
119
+ if isinstance(key, vol.Required):
120
+ # Extract candidate keys and their descriptions from Any validator
121
+ candidate_items = []
122
+ for val_item in pkey.validators:
123
+ item_key = ""
124
+ item_desc = None
125
+ if isinstance(val_item, vol.Marker):
126
+ item_key = str(val_item.schema)
127
+ item_desc = val_item.description
116
128
  else:
117
- properties[str(val)] = pval
118
- else:
119
- properties[str(val)] = pval
129
+ item_key = str(val_item)
130
+ candidate_items.append({"key": item_key, "desc": item_desc})
131
+
132
+ candidate_keys = [item["key"] for item in candidate_items]
133
+ any_of_constraint_groups.append(candidate_keys)
134
+
135
+ # If the value is not a wildcard, create properties for each
136
+ # candidate key, preserving any descriptions.
137
+ if value is not object:
138
+ for item in candidate_items:
139
+ prop_schema = pval.copy()
140
+ final_description = item["desc"] or description
141
+ if final_description:
142
+ prop_schema["description"] = final_description
143
+ properties[item["key"]] = prop_schema
144
+ else:
145
+ # Handle Optional(Any(...)) - expand to individual properties
146
+ for val_item in pkey.validators:
147
+ if isinstance(val_item, vol.Marker):
148
+ if val_item.description:
149
+ properties[str(val_item.schema)] = pval.copy()
150
+ properties[str(val_item.schema)][
151
+ "description"
152
+ ] = val_item.description
153
+ else:
154
+ properties[str(val_item)] = pval
155
+ else:
156
+ properties[str(val_item)] = pval
120
157
  elif isinstance(pkey, str):
121
158
  properties[pkey] = pval
122
159
  else:
@@ -126,15 +163,21 @@ def convert(
126
163
  if additional_properties is None:
127
164
  additional_properties = pval
128
165
 
129
- if isinstance(key, vol.Required) and not isinstance(pkey, vol.Any):
130
- required.append(str(pkey))
131
-
132
166
  val = {"type": "object"}
133
167
  if properties or not additional_properties:
134
168
  val["properties"] = properties
135
169
  val["required"] = required
136
170
  if additional_properties:
137
171
  val["additionalProperties"] = additional_properties
172
+
173
+ # Generate anyOf constraints from the Cartesian product of constraint groups
174
+ if any_of_constraint_groups:
175
+ # Generate all combinations (Cartesian product) of the constraint groups
176
+ val["anyOf"] = [
177
+ {"required": list(combination)}
178
+ for combination in itertools.product(*any_of_constraint_groups)
179
+ ]
180
+
138
181
  return val
139
182
 
140
183
  if isinstance(schema, vol.All):
@@ -426,7 +469,16 @@ def convert_to_voluptuous(schema: dict) -> vol.Schema:
426
469
  if (any_of := schema.get("anyOf")) is not None:
427
470
  if not isinstance(any_of, list):
428
471
  raise ValueError("Invalid schema, anyOf should be a list")
429
- return vol.Any(*[convert_to_voluptuous(sub_schema) for sub_schema in any_of])
472
+ # If the anyOf is a list of required properties, it's a constraint on an
473
+ # object and should be handled by the object validator.
474
+ is_required_constraint = all(
475
+ list(s.keys()) == ["required"] and isinstance(s["required"], list)
476
+ for s in any_of
477
+ )
478
+ if not (is_required_constraint and schema.get("type") == "object"):
479
+ return vol.Any(
480
+ *[convert_to_voluptuous(sub_schema) for sub_schema in any_of]
481
+ )
430
482
 
431
483
  if (schema_type := schema.get("type")) is None:
432
484
  raise ValueError("Invalid schema, missing type")
@@ -491,6 +543,13 @@ def convert_to_voluptuous(schema: dict) -> vol.Schema:
491
543
  key_type = vol.Optional
492
544
  properties[key_type(key, description=description)] = value_type
493
545
 
546
+ if (any_of := schema.get("anyOf")) is not None:
547
+ any_of_keys = [
548
+ key for sub_schema in any_of for key in sub_schema.get("required", [])
549
+ ]
550
+ if any_of_keys:
551
+ properties[vol.Required(vol.Any(*any_of_keys))] = object
552
+
494
553
  validator = None
495
554
  if schema.get("additionalProperties") is True:
496
555
  validator = vol.Schema(properties, extra=vol.ALLOW_EXTRA)
@@ -504,7 +563,16 @@ def convert_to_voluptuous(schema: dict) -> vol.Schema:
504
563
  return validator
505
564
 
506
565
  if schema_type == "array":
507
- validator = vol.Schema([convert_to_voluptuous(schema["items"])])
566
+ item_validator = convert_to_voluptuous(schema["items"])
567
+ min_items = schema.get("minItems")
568
+ max_items = schema.get("maxItems")
569
+
570
+ if min_items is not None or max_items is not None:
571
+ validator = vol.Schema(
572
+ vol.All([item_validator], vol.Length(min=min_items, max=max_items))
573
+ )
574
+ else:
575
+ validator = vol.Schema([item_validator])
508
576
 
509
577
  # Handle OpenAPI 3.0 nullable property
510
578
  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.3.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