diff --git a/src/OpenApiLibCore/models/oas_models.py b/src/OpenApiLibCore/models/oas_models.py index 6f72be8..2beb923 100644 --- a/src/OpenApiLibCore/models/oas_models.py +++ b/src/OpenApiLibCore/models/oas_models.py @@ -7,6 +7,7 @@ from functools import cached_property from random import choice, randint, sample, shuffle, uniform from sys import float_info +from decimal import Decimal, ROUND_CEILING, ROUND_FLOOR from typing import ( Annotated, Any, @@ -331,7 +332,7 @@ class IntegerSchema(SchemaBase[int], frozen=True): exclusiveMaximum: int | bool | None = None minimum: int | None = None exclusiveMinimum: int | bool | None = None - multipleOf: int | None = None # TODO: implement support + multipleOf: float | None = Field(default=None, gt=0) const: int | None = None enum: list[int] | None = None nullable: bool = False @@ -390,7 +391,29 @@ def get_valid_value( if self.enum is not None: return choice(self.enum), self - return randint(self._min_value, self._max_value), self + if self.multipleOf is None: + return randint(self._min_value, self._max_value), self + + step = self.multipleOf + if step <= 0: + logger.debug(f"multipleOf must be > 0, got {self.multipleOf}") + return randint(self._min_value, self._max_value), self + + # k_min and k_max are the bounds for the integer k, which will be chosen randomly, + # then multiplied by the step (multipleOf) to get a valid value. + k_min = -(-self._min_value // step) # the "double negative" essentially turns floor division into ceiling division + k_max = self._max_value // step # floor division + + if k_min > k_max: + logger.debug( + f"No number satisfies bounds [{self._min_value}, {self._max_value}] " + f"and multipleOf {self.multipleOf}" + ) + return randint(self._min_value, self._max_value), self + + # choose a k randomly between k_min and k_max, then multiply by step + value = randint(k_min, k_max) * step + return value, self def get_values_out_of_bounds(self, current_value: int) -> list[int]: # pylint: disable=unused-argument invalid_values: list[int] = [] @@ -401,6 +424,8 @@ def get_values_out_of_bounds(self, current_value: int) -> list[int]: # pylint: if self._max_value < self._max_int: invalid_values.append(self._max_value + 1) + # TODO: handle multipleOf for out of bounds values + if invalid_values: return invalid_values @@ -452,7 +477,7 @@ class NumberSchema(SchemaBase[float], frozen=True): exclusiveMaximum: int | float | bool | None = None minimum: int | float | None = None exclusiveMinimum: int | float | bool | None = None - multipleOf: int | None = None # TODO: implement support + multipleOf: float | None = Field(default=None, gt=0) const: int | float | None = None enum: list[int | float] | None = None nullable: bool = False @@ -507,7 +532,35 @@ def get_valid_value( if self.enum is not None: return choice(self.enum), self - return uniform(self._min_value, self._max_value), self + if self.multipleOf is None: + return uniform(self._min_value, self._max_value), self + + #Convert multipleOf and bounds to Decimal to avoid float rounding errors. + step = Decimal(str(self.multipleOf)) + if step <= 0: + logger.debug(f"multipleOf must be > 0, got {self.multipleOf}") + return uniform(self._min_value, self._max_value), self + + min_value = Decimal(str(self._min_value)) + max_value = Decimal(str(self._max_value)) + + #k_min and k_max are the bounds for the integer k, which will be chosen randomly, + # then multiplied by the step (multipleOf) to get a valid value. + # dividing by step and using ceiling/floor ensures then rounding ensures that k_min and k_max + # are the smallest/largest integers that satisfy the bounds when multiplied by step. + k_min = int((min_value / step).to_integral_value(rounding=ROUND_CEILING)) + k_max = int((max_value / step).to_integral_value(rounding=ROUND_FLOOR)) + + if k_min > k_max: + logger.debug( + f"No number satisfies bounds [{self._min_value}, {self._max_value}] " + f"and multipleOf {self.multipleOf}" + ) + return uniform(self._min_value, self._max_value), self + + #choose a k randomly between k_min and k_max, then multiply by step + value = float(Decimal(randint(k_min, k_max)) * step) + return value, self def get_values_out_of_bounds(self, current_value: float) -> list[float]: # pylint: disable=unused-argument invalid_values: list[float] = [] @@ -518,6 +571,8 @@ def get_values_out_of_bounds(self, current_value: float) -> list[float]: # pyli if self._max_value < self._max_float: invalid_values.append(self._max_value + 0.000000001) + # TODO: handle multipleOf for out of bounds values + if invalid_values: return invalid_values diff --git a/tests/libcore/unittests/oas_model/test_get_valid_value.py b/tests/libcore/unittests/oas_model/test_get_valid_value.py index 28a4297..94801ae 100644 --- a/tests/libcore/unittests/oas_model/test_get_valid_value.py +++ b/tests/libcore/unittests/oas_model/test_get_valid_value.py @@ -167,6 +167,91 @@ def test_pattern(self) -> None: ) +class TestIntegerSchemaVariations(unittest.TestCase): + def test_unbounded_multipleof(self) -> None: + # Always true, shouldn't cause any issues + multiple_of = 1 + schema = IntegerSchema(multipleOf=multiple_of) + value = schema.get_valid_value()[0] + factor = value / multiple_of + self.assertTrue(factor.is_integer()) + + # The multipleOf is a float, for integers this means the factor will be a + # mutliple of 10, 100, 1000, etc. depending on the decimals + multiple_of = 0.71 + schema = IntegerSchema(multipleOf=multiple_of) + value = schema.get_valid_value()[0] + factor = value / multiple_of + self.assertTrue(factor.is_integer()) + + def test_multipleof_with_min(self) -> None: + # This multiple_of is just within the min/max value of the JSON spec + # for (default) int32 so the unbounded factors can only be -1 and 1 + multiple_of = 2000000000 + schema = IntegerSchema(multipleOf=multiple_of, minimum=0) + value = schema.get_valid_value()[0] + factor = value / multiple_of + self.assertTrue(factor.is_integer()) + self.assertTrue((int(factor)) == 1) + + def test_multipleof_with_max(self) -> None: + multiple_of = 2000000000 + schema = IntegerSchema(multipleOf=multiple_of, maximum=0) + value = schema.get_valid_value()[0] + factor = value / multiple_of + self.assertTrue(factor.is_integer()) + self.assertTrue((int(factor)) == -1) + + def test_multipleof_with_min_and_max(self) -> None: + multiple_of = 3.0 + schema = IntegerSchema(multipleOf=multiple_of, minimum=-7, maximum=5) + value = schema.get_valid_value()[0] + factor = value / multiple_of + self.assertTrue(factor.is_integer()) + self.assertIn(value, [-6, -3, 3]) + + +class TestNumberSchemaVariations(unittest.TestCase): + def test_unbounded_multipleof(self) -> None: + multiple_of = 2 + schema = NumberSchema(multipleOf=multiple_of) + value = schema.get_valid_value()[0] + factor = value / multiple_of + self.assertTrue(factor.is_integer()) + + multiple_of = 0.7 + schema = NumberSchema(multipleOf=multiple_of) + value = schema.get_valid_value()[0] + factor = value / multiple_of + self.assertTrue(factor.is_integer()) + + def test_multipleof_with_min(self) -> None: + # This multiple_of is just within the min/max value of the JSON spec + # so the unbounded factors can only be -1 and 1 + multiple_of = 9000000000000000000 + schema = NumberSchema(multipleOf=multiple_of, minimum=0) + value = schema.get_valid_value()[0] + factor = value / multiple_of + self.assertTrue(factor.is_integer()) + self.assertTrue((int(factor)) == 1) + + def test_multipleof_with_max(self) -> None: + multiple_of = 9000000000000000000 + schema = NumberSchema(multipleOf=multiple_of, maximum=0) + value = schema.get_valid_value()[0] + factor = value / multiple_of + self.assertTrue(factor.is_integer()) + self.assertTrue((int(factor)) == -1) + + def test_multipleof_with_min_and_max(self) -> None: + multiple_of = 3.11 + schema = NumberSchema(multipleOf=multiple_of, minimum=-7, maximum=6) + value = schema.get_valid_value()[0] + factor = value / multiple_of + self.assertTrue(factor.is_integer()) + self.assertIn(value, [-6.22, -3.11, 3.11]) + + class TestArraySchemaVariations(unittest.TestCase): def test_default_min_max(self) -> None: schema = ArraySchema(items=StringSchema())