Skip to content

Commit a74b3ec

Browse files
committed
fix: preserve underscores when creating integers from strings
TOML 1.0 allows grouping underscores in integers (1_000, 0xdead_beef), and tomlkit parses them fine, but api.integer("1_000") silently normalized the raw text to "1000" because it built the item via item(int(raw)), discarding the original string. Validate string input against the TOML v1.0.0 integer grammar and preserve it verbatim as the item's raw text; invalid strings raise ValueError. The parse path and integer(int) behavior are unchanged. Fixes #336. Signed-off-by: Junyi Yao <j.yao@wustl.edu>
1 parent 8c959b5 commit a74b3ec

2 files changed

Lines changed: 85 additions & 1 deletion

File tree

‎tests/test_api.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,3 +611,61 @@ def test_parse_accepts_nesting_at_the_depth_limit() -> None:
611611
assert parse(array).as_string() == array
612612
dotted = ".".join(["a"] * depth) + " = 1"
613613
assert parse(dotted).as_string() == dotted
614+
615+
616+
@pytest.mark.parametrize(
617+
"raw",
618+
[
619+
"0",
620+
"42",
621+
"1_000",
622+
"5_349_221",
623+
"+1_000",
624+
"-1_000",
625+
"0xdead_beef",
626+
"0xDEAD_BEEF",
627+
"0o7_7",
628+
"0b1_0",
629+
],
630+
)
631+
def test_integer_preserves_grouping_underscores(raw: str) -> None:
632+
assert tomlkit.integer(raw).as_string() == raw
633+
assert int(tomlkit.integer(raw)) == int(raw, 0)
634+
635+
636+
@pytest.mark.parametrize("raw", ["1_000", "0xdead_beef", "+1_000", "0o7_7"])
637+
def test_integer_emission_matches_parse(raw: str) -> None:
638+
doc = tomlkit.document()
639+
doc["n"] = tomlkit.integer(raw)
640+
assert tomlkit.dumps(doc) == f"n = {raw}\n"
641+
# the emission path agrees with the parse path on the same literal
642+
assert tomlkit.integer(raw).as_string() == tomlkit.parse(f"n = {raw}\n")["n"].as_string()
643+
644+
645+
@pytest.mark.parametrize(
646+
"raw",
647+
[
648+
"",
649+
"abc",
650+
"_100",
651+
"100_",
652+
"1__000",
653+
"01_2",
654+
"0x_1",
655+
"0X1", # the parser only accepts a lowercase prefix
656+
"+0x1", # hex/oct/bin take no sign
657+
"-0o7",
658+
" 1",
659+
"1 ",
660+
"1.0",
661+
"1e3",
662+
],
663+
)
664+
def test_integer_rejects_invalid_strings(raw: str) -> None:
665+
with pytest.raises(ValueError):
666+
tomlkit.integer(raw)
667+
668+
669+
def test_integer_from_int_unchanged() -> None:
670+
assert tomlkit.integer(1000).as_string() == "1000"
671+
assert int(tomlkit.integer(1000)) == 1000

‎tomlkit/api.py‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import contextlib
44
import datetime as _datetime
5+
import re
56

67
from collections.abc import Iterable
78
from collections.abc import Mapping
@@ -114,8 +115,33 @@ def document() -> TOMLDocument:
114115

115116

116117
# Items
118+
_INTEGER_RE = re.compile(
119+
r"(?:"
120+
r"0x[0-9a-fA-F](?:_?[0-9a-fA-F])*" # hex: lowercase prefix only, no sign
121+
r"|0o[0-7](?:_?[0-7])*" # octal: lowercase prefix only, no sign
122+
r"|0b[01](?:_?[01])*" # binary: lowercase prefix only, no sign
123+
r"|[+-]?(?:0|[1-9](?:_?[0-9])*)" # decimal: optional sign, no leading zeros
124+
r")"
125+
)
126+
"""Valid TOML v1.0.0 integer literals (https://toml.io/en/v1.0.0#integer).
127+
128+
Underscores may only appear between digits; hex/octal/binary use a
129+
lowercase prefix and take no sign, mirroring ``Parser._parse_number``.
130+
"""
131+
132+
117133
def integer(raw: str | int) -> Integer:
118-
"""Create an integer item from a number or string."""
134+
"""Create an integer item from a number or string.
135+
136+
When ``raw`` is a string it must be a valid TOML v1.0.0 integer
137+
literal; the original text is then preserved verbatim so grouping
138+
underscores (e.g. ``"1_000"``) survive ``dumps()``. Anything else
139+
raises ``ValueError``.
140+
"""
141+
if isinstance(raw, str):
142+
if not _INTEGER_RE.fullmatch(raw):
143+
raise ValueError(f"Invalid TOML integer: {raw!r}")
144+
return Integer(int(raw, 0), Trivia(), raw)
119145
return item(int(raw))
120146

121147

0 commit comments

Comments
 (0)