Skip to content

Commit e950ca3

Browse files
committed
Improved multipleOf
1 parent 3d343ff commit e950ca3

File tree

4 files changed

+53
-7
lines changed

4 files changed

+53
-7
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Changelog
22

3+
#### 0.20.1 - 2021-06-03
4+
- improved handling for fractional `multipleOf` values
5+
36
#### 0.20.0 - 2021-06-02
47
- Allow `custom_formats` for known string formats (#83)
58

src/hypothesis_jsonschema/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
The only public API is `from_schema`; check the docstring for details.
44
"""
55

6-
__version__ = "0.20.0"
6+
__version__ = "0.20.1"
77
__all__ = ["from_schema"]
88

99
from ._from_schema import from_schema

src/hypothesis_jsonschema/_canonicalise.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import json
1717
import math
1818
import re
19+
from fractions import Fraction
1920
from typing import Any, Dict, List, Optional, Tuple, Union
2021

2122
import jsonschema
@@ -263,6 +264,9 @@ def canonicalish(schema: JSONType) -> Dict[str, Any]:
263264
k: v if isinstance(v, list) else canonicalish(v)
264265
for k, v in schema[key].items()
265266
}
267+
# multipleOf is semantically unaffected by the sign, so ensure it's positive
268+
if "multipleOf" in schema:
269+
schema["multipleOf"] = abs(schema["multipleOf"])
266270

267271
type_ = get_type(schema)
268272
if "number" in type_:
@@ -289,6 +293,10 @@ def canonicalish(schema: JSONType) -> Dict[str, Any]:
289293
if "integer" in type_:
290294
lo, hi = get_integer_bounds(schema)
291295
mul = schema.get("multipleOf")
296+
if mul is not None and "number" not in type_ and Fraction(mul).numerator == 1:
297+
# Every integer is a multiple of 1/n for all natural numbers n.
298+
schema.pop("multipleOf")
299+
mul = None
292300
if lo is not None and isinstance(mul, int) and mul > 1 and (lo % mul):
293301
lo += mul - (lo % mul)
294302
if hi is not None and isinstance(mul, int) and mul > 1 and (hi % mul):
@@ -303,6 +311,8 @@ def canonicalish(schema: JSONType) -> Dict[str, Any]:
303311

304312
if lo is not None and hi is not None and lo > hi:
305313
type_.remove("integer")
314+
elif type_ == ["integer"] and lo == hi and make_validator(schema).is_valid(lo):
315+
return {"const": lo}
306316

307317
if "array" in type_ and "contains" in schema:
308318
if isinstance(schema.get("items"), dict):
@@ -542,11 +552,8 @@ def canonicalish(schema: JSONType) -> Dict[str, Any]:
542552
tmp = schema.copy()
543553
ao = tmp.pop("allOf")
544554
out = merged([tmp] + ao)
545-
if isinstance(out, dict): # pragma: no branch
555+
if out is not None:
546556
schema = out
547-
# TODO: this assertion is soley because mypy 0.750 doesn't know
548-
# that `schema` is a dict otherwise. Needs minimal report upstream.
549-
assert isinstance(schema, dict)
550557
if "oneOf" in schema:
551558
one_of = schema.pop("oneOf")
552559
assert isinstance(one_of, list)
@@ -701,8 +708,8 @@ def merged(schemas: List[Any]) -> Optional[Schema]:
701708
if isinstance(x, int) and isinstance(y, int):
702709
out["multipleOf"] = x * y // math.gcd(x, y)
703710
elif x != y:
704-
ratio = max(x, y) / min(x, y)
705-
if ratio == int(ratio): # e.g. x=0.5, y=2
711+
ratio = Fraction(max(x, y)) / Fraction(min(x, y))
712+
if ratio.denominator == 1: # e.g. .75, 1.5
706713
out["multipleOf"] = max(x, y)
707714
else:
708715
return None

tests/test_canonicalise.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,31 @@ def test_canonicalises_to_equivalent_fixpoint(schema_strategy, data):
4141
jsonschema.validators.validator_for(schema).check_schema(schema)
4242

4343

44+
@pytest.mark.parametrize(
45+
"schema, examples",
46+
[
47+
# See https://github.com/Julian/jsonschema/pull/746
48+
pytest.param(
49+
{"type": "integer", "multipleOf": 0.75},
50+
[1.5e308],
51+
marks=pytest.mark.xfail(raises=OverflowError),
52+
),
53+
],
54+
)
55+
def test_canonicalises_to_equivalent_fixpoint_examples(schema, examples):
56+
"""Check that an object drawn from an arbitrary schema is valid.
57+
58+
This is used to record past regressions from the test above.
59+
"""
60+
cc = canonicalish(schema)
61+
assert cc == canonicalish(cc)
62+
validator = jsonschema.validators.validator_for(schema)
63+
validator.check_schema(schema)
64+
validator.check_schema(cc)
65+
for instance in examples:
66+
assert is_valid(instance, schema) == is_valid(instance, cc)
67+
68+
4469
def test_dependencies_canonicalises_to_fixpoint():
4570
"""Check that an object drawn from an arbitrary schema is valid."""
4671
cc = canonicalish(
@@ -136,6 +161,9 @@ def test_canonicalises_to_empty(schema):
136161
({"type": get_type({})}, {}),
137162
({"required": []}, {}),
138163
({"type": "integer", "not": {"type": "string"}}, {"type": "integer"}),
164+
({"type": "integer", "multipleOf": 1 / 32}, {"type": "integer"}),
165+
({"type": "number", "multipleOf": 1.0}, {"type": "integer"}),
166+
({"type": "number", "multipleOf": -3.0}, {"type": "integer", "multipleOf": 3}),
139167
(
140168
{"type": "array", "items": [True, False, True]},
141169
{"type": "array", "items": [{}], "maxItems": 1},
@@ -276,6 +304,14 @@ def test_canonicalises_to_empty(schema):
276304
{"if": {"type": "integer"}, "then": {}, "else": {}, "type": "number"},
277305
{"type": "number"},
278306
),
307+
(
308+
{"allOf": [{"multipleOf": 1.5}], "multipleOf": 1.5},
309+
{"multipleOf": 1.5},
310+
),
311+
(
312+
{"type": "integer", "allOf": [{"multipleOf": 0.5}, {"multipleOf": 1e308}]},
313+
{"type": "integer", "multipleOf": 1e308},
314+
),
279315
],
280316
)
281317
def test_canonicalises_to_expected(schema, expected):

0 commit comments

Comments
 (0)