Skip to content

Commit 73078ea

Browse files
authored
Speed up escaping strings and keys when writing TOML (#709)
1 parent 252d23f commit 73078ea

4 files changed

Lines changed: 51 additions & 42 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Changed
66

77
- Speed up repeated array appends by keeping existing indexes instead of rebuilding them.
8+
- Speed up creating string values and keys, e.g. when building a document from Python data and dumping it: strings are escaped in one pass instead of per character, and the escape and bare-key character sets are built once.
89

910
### Fixed
1011

‎tests/test_write.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,18 @@ def test_escape_special_characters_in_key() -> None:
5353
assert result == "baz"
5454

5555

56+
def test_write_escapes_every_sequence_in_a_string() -> None:
57+
value = 'say "hi" \\ \x00\t\x7f """"" end'
58+
d = {"a": value, "m": string(value + "\n", multiline=True)}
59+
expected = (
60+
'a = "say \\"hi\\" \\\\ \\u0000\\t\\u007f \\"\\"\\"\\"\\" end"\n'
61+
'm = """say "hi" \\\\ \\u0000\\t\\u007f ""\\""" end\n"""\n'
62+
)
63+
64+
assert expected == dumps(d)
65+
assert loads(dumps(d)) == d
66+
67+
5668
def test_write_inline_table_in_nested_arrays() -> None:
5769
d = {"foo": [[{"a": 1}]]}
5870
expected = "foo = [[{a = 1}]]\n"

‎tomlkit/_utils.py‎

Lines changed: 16 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -136,33 +136,25 @@ def _unicode_escape(seq: str) -> str:
136136
return "".join(f"\\u{ord(c):04x}" for c in seq)
137137

138138

139-
def escape_string(s: str, escape_sequences: Collection[str] = _basic_escapes) -> str:
140-
s = decode(s)
141-
142-
res = []
143-
start = 0
144-
145-
def flush(inc: int = 1) -> int:
146-
if start != i:
147-
res.append(s[start:i])
139+
def _escape_match(match: re.Match[str]) -> str:
140+
seq = match.group()
141+
return _compact_escapes.get(seq) or _unicode_escape(seq)
148142

149-
return i + inc
150143

151-
found_sequences = {seq for seq in escape_sequences if seq in s}
152-
153-
i = 0
154-
while i < len(s):
155-
for seq in found_sequences:
156-
seq_len = len(seq)
157-
if s[i:].startswith(seq):
158-
start = flush(seq_len)
159-
res.append(_compact_escapes.get(seq) or _unicode_escape(seq))
160-
i += seq_len - 1 # fast-forward escape sequence
161-
i += 1
162-
163-
flush()
144+
def escape_string(s: str, escape_sequences: Collection[str] = _basic_escapes) -> str:
145+
s = decode(s)
164146

165-
return "".join(res)
147+
found_sequences = [seq for seq in escape_sequences if seq in s]
148+
if not found_sequences:
149+
# always return a plain str, also for str subclasses such as String
150+
return str(s)
151+
152+
# Replace every sequence in a single left-to-right pass. Longer sequences
153+
# come first so that '"""' is matched as a whole.
154+
pattern = "|".join(
155+
re.escape(seq) for seq in sorted(found_sequences, key=len, reverse=True)
156+
)
157+
return re.sub(pattern, _escape_match, s)
166158

167159

168160
def merge_dicts(d1: dict[str, Any], d2: dict[str, Any]) -> None:

‎tomlkit/items.py‎

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,8 @@ def _format_datetime(value: datetime) -> str:
306306
# a control character that a basic string must escape.
307307
_BARE_CR = re.compile(r"\r(?!\n)")
308308

309+
_BARE_KEY_CHARS = frozenset(string.ascii_letters + string.digits + "-_")
310+
309311

310312
class StringType(Enum):
311313
# Single Line Basic
@@ -329,27 +331,14 @@ def select(cls, literal: bool = False, multiline: bool = False) -> StringType:
329331
@property
330332
def escaped_sequences(self) -> Collection[str]:
331333
# https://toml.io/en/v1.0.0#string
332-
escaped_in_basic = CONTROL_CHARS | {"\\"}
333-
allowed_in_multiline = {"\n", "\r"}
334-
return {
335-
StringType.SLB: escaped_in_basic | {'"'},
336-
StringType.MLB: (escaped_in_basic | {'"""'}) - allowed_in_multiline,
337-
StringType.SLL: (),
338-
StringType.MLL: (),
339-
}[self]
334+
return _ESCAPED_SEQUENCES[self]
340335

341336
@property
342337
def invalid_sequences(self) -> Collection[str]:
343338
# https://toml.io/en/v1.0.0#string
344-
forbidden_in_literal = CONTROL_CHARS - {"\t"}
345339
# CR stays forbidden in multiline: the value is checked with CRLF
346340
# pairs collapsed, so any remaining CR is a lone control character
347-
return {
348-
StringType.SLB: (),
349-
StringType.MLB: (),
350-
StringType.SLL: forbidden_in_literal | {"'"},
351-
StringType.MLL: (forbidden_in_literal | {"'''"}) - {"\n"},
352-
}[self]
341+
return _INVALID_SEQUENCES[self]
353342

354343
@property
355344
def unit(self) -> str:
@@ -376,6 +365,23 @@ def toggle(self) -> StringType:
376365
}[self]
377366

378367

368+
_ESCAPED_IN_BASIC = CONTROL_CHARS | {"\\"}
369+
_FORBIDDEN_IN_LITERAL = CONTROL_CHARS - {"\t"}
370+
_ESCAPED_SEQUENCES: dict[StringType, Collection[str]] = {
371+
StringType.SLB: _ESCAPED_IN_BASIC | {'"'},
372+
# raw line breaks are allowed in multiline strings
373+
StringType.MLB: (_ESCAPED_IN_BASIC | {'"""'}) - {"\n", "\r"},
374+
StringType.SLL: (),
375+
StringType.MLL: (),
376+
}
377+
_INVALID_SEQUENCES: dict[StringType, Collection[str]] = {
378+
StringType.SLB: (),
379+
StringType.MLB: (),
380+
StringType.SLL: _FORBIDDEN_IN_LITERAL | {"'"},
381+
StringType.MLL: (_FORBIDDEN_IN_LITERAL | {"'''"}) - {"\n"},
382+
}
383+
384+
379385
class BoolType(Enum):
380386
TRUE = "true"
381387
FALSE = "false"
@@ -480,9 +486,7 @@ def __init__(
480486
raise TypeError("Keys must be strings")
481487

482488
if t is None:
483-
if not k or any(
484-
c not in string.ascii_letters + string.digits + "-" + "_" for c in k
485-
):
489+
if not k or not _BARE_KEY_CHARS.issuperset(k):
486490
t = KeyType.Basic
487491
else:
488492
t = KeyType.Bare

0 commit comments

Comments
 (0)