Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 59 additions & 4 deletions src/OpenApiLibCore/models/oas_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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] = []
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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] = []
Expand All @@ -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

Expand Down
85 changes: 85 additions & 0 deletions tests/libcore/unittests/oas_model/test_get_valid_value.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down