From 2d1b1c631fc8142d8a3a6fc9732e445656b62564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afonso=20Janu=C3=A1rio?= Date: Sun, 13 Sep 2026 18:30:46 +0100 Subject: [PATCH] Stop a literal decimal point from being collapsed to an int The number parser in decoder.py used to check whether a parsed float was numerically whole and, if so, cast it to int regardless of how the value was written. That means "1.0" and "10.00" came back as plain ints, even though hjson's own serializer round-trips a float 1.0 back to "1.0", and the plain json module keeps it a float too. The collapsing logic is genuinely useful for a case like "17.01e2", where the exponent (not the decimal point) is what pushes the value to a whole number, and existing fixtures already rely on that giving an int. This change narrows the collapse so it only fires when an exponent is present, leaving a bare decimal literal such as "1.0" as a float. Updated the comments_test fixture's expected output to match (its num2 field is 0.0, not 0) and added a couple of decode tests pinning both the fixed and preserved behavior. --- hjson/decoder.py | 7 ++++++- hjson/tests/assets/comments_result.hjson | 2 +- hjson/tests/assets/comments_result.json | 2 +- hjson/tests/test_decode.py | 24 ++++++++++++++++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/hjson/decoder.py b/hjson/decoder.py index fbcc2a2..1d16390 100644 --- a/hjson/decoder.py +++ b/hjson/decoder.py @@ -253,7 +253,12 @@ def scantfnns(context, s, end): integer, frac, exp = m.groups() if frac or exp: res = context.parse_float(integer + (frac or '') + (exp or '')) - if int(res) == res and abs(res)<1e10: res = int(res) + # Only collapse to int when the exponent is what made the + # value a whole number (e.g. "17.01e2" -> 1701). A plain + # decimal literal like "1.0" or "10.00" was written as a + # float on purpose and should stay one, matching what + # dumps() produces for it and what json.loads() would do. + if exp and int(res) == res and abs(res)<1e10: res = int(res) else: res = context.parse_int(integer) return res, end diff --git a/hjson/tests/assets/comments_result.hjson b/hjson/tests/assets/comments_result.hjson index a99ce23..1060d06 100644 --- a/hjson/tests/assets/comments_result.hjson +++ b/hjson/tests/assets/comments_result.hjson @@ -9,7 +9,7 @@ rem2: "// test" rem3: "/* test */" num1: 0 - num2: 0 + num2: 0.0 num3: 2 true1: true true2: true diff --git a/hjson/tests/assets/comments_result.json b/hjson/tests/assets/comments_result.json index e247803..ca4076d 100644 --- a/hjson/tests/assets/comments_result.json +++ b/hjson/tests/assets/comments_result.json @@ -9,7 +9,7 @@ "rem2": "// test", "rem3": "/* test */", "num1": 0, - "num2": 0, + "num2": 0.0, "num3": 2, "true1": true, "true2": true, diff --git a/hjson/tests/test_decode.py b/hjson/tests/test_decode.py index cdab0ef..14abe8f 100644 --- a/hjson/tests/test_decode.py +++ b/hjson/tests/test_decode.py @@ -24,6 +24,30 @@ def test_float(self): self.assertTrue(isinstance(rval, float)) self.assertEqual(rval, 1.0) + def test_decimal_point_is_not_dropped(self): + # A literal that was written with a decimal point should stay a + # float even when its fractional part is all zeros, since the + # author explicitly chose the float form. Silently turning it + # into an int both loses that distinction and breaks round + # tripping: dumps(1.0) already produces "1.0", so loads("1.0") + # ought to hand back a float rather than an int. + for text, expected in ( + ("1.0", 1.0), + ("10.00", 10.0), + ("-3.0", -3.0), + ): + rval = json.loads(text) + self.assertTrue(isinstance(rval, float), (text, rval)) + self.assertEqual(rval, expected) + + def test_exponent_can_still_collapse_to_int(self): + # When it's the exponent that turns the value into a whole + # number, keeping it an int is fine: the decimal point in the + # source wasn't the last word on the value's magnitude. + rval = json.loads("17.01e2") + self.assertTrue(isinstance(rval, int)) + self.assertEqual(rval, 1701) + def test_decoder_optimizations(self): # Several optimizations were made that skip over calls to # the whitespace regex, so this test is designed to try and