Skip to content

Commit ef19ad2

Browse files
committed
Update check_markup.py and fix sphinx.po markup issue
1 parent 5d4bbc4 commit ef19ad2

2 files changed

Lines changed: 92 additions & 2 deletions

File tree

scripts/check_markup.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
be translated, matching Sphinx's own translation convention.
99
- Literal/code spans (``...``), substitution refs (|...|), and %s/{name}
1010
placeholders — these must match verbatim, since they're not prose.
11+
- Invalid C-string escape sequences in msgstr (a lone backslash followed
12+
by a character that isn't one of \\ " n t r f b a v) — these make
13+
`msgfmt` fail with "invalid control sequence" and must be found before
14+
they break a build.
1115
1216
Output is grouped by file, with a per-file mismatch count and a grand
1317
total at the end.
@@ -44,6 +48,68 @@
4448
("brace placeholder", re.compile(r"\{[^{}\s]*\}")),
4549
]
4650

51+
# Valid C-string escapes that gettext/msgfmt accept inside a quoted
52+
# string. A backslash followed by anything else is what msgfmt rejects
53+
# with "invalid control sequence". This mirrors the set the old
54+
# autofix bash script treated as "leave alone": \\ \" \n \t \r \f \b \a \v
55+
VALID_ESCAPE_CHARS = set('\\"ntrfbav')
56+
57+
# One raw quoted-string line as it appears in the .po file. Matches
58+
# both the first line of an entry, which carries a keyword prefix
59+
# (msgid "..." / msgstr "..." / msgstr[0] "..."), and bare continuation
60+
# lines ("...") that follow it. Captured group is the RAW content
61+
# between the quotes - escapes are NOT decoded, which is required for
62+
# this check (see note below).
63+
RAW_STRING_LINE = re.compile(
64+
r'^(?:msgid|msgstr(?:\[\d+\])?|msgctxt)?\s*"((?:[^"\\]|\\.)*)"\s*$'
65+
)
66+
67+
68+
def find_invalid_escapes_in_raw(raw: str):
69+
"""Scan raw (undecoded) quoted-string content left-to-right the way
70+
a C-string tokenizer would, consuming two characters whenever a
71+
backslash is seen, and return the invalid `\\X` sequences found.
72+
73+
MUST run on raw file text, not on polib's .msgid/.msgstr - polib
74+
decodes \\\\ into a single \\ before Claude ever sees it, so scanning
75+
decoded text turns safe, doubled backslashes (\\\\d in the file,
76+
meaning a literal backslash-d) into false positives that look like
77+
a lone backslash followed by an invalid character."""
78+
invalid = []
79+
i = 0
80+
n = len(raw)
81+
while i < n:
82+
if raw[i] == "\\":
83+
if i + 1 < n:
84+
nxt = raw[i + 1]
85+
if nxt not in VALID_ESCAPE_CHARS:
86+
invalid.append("\\" + nxt)
87+
i += 2
88+
continue
89+
else:
90+
invalid.append("\\<end-of-line>")
91+
i += 1
92+
continue
93+
i += 1
94+
return invalid
95+
96+
97+
def check_raw_escapes(path: Path):
98+
"""Line-by-line scan of the raw file for invalid escape sequences,
99+
independent of polib. Returns a list of (line_no, line_text,
100+
invalid_list) for every quoted-string line with a problem."""
101+
findings = []
102+
for lineno, line in enumerate(
103+
path.read_text(encoding="utf-8").splitlines(), start=1
104+
):
105+
m = RAW_STRING_LINE.match(line.strip())
106+
if not m:
107+
continue
108+
invalid = find_invalid_escapes_in_raw(m.group(1))
109+
if invalid:
110+
findings.append((lineno, line.strip(), invalid))
111+
return findings
112+
47113

48114
def extract_role_targets(text: str):
49115
"""For each Sphinx role, return its target: the <target> anchor if
@@ -60,6 +126,7 @@ def check_file(path: Path):
60126
"""Return a list of finding-dicts for this file (empty if none)."""
61127
results = []
62128
po = polib.pofile(str(path))
129+
63130
for entry in po:
64131
if entry.obsolete or not entry.msgid or not entry.msgstr:
65132
continue # obsolete entry, header, or still untranslated
@@ -134,6 +201,8 @@ def main():
134201

135202
total = 0
136203
per_file_counts = []
204+
escape_total = 0
205+
escape_files = 0
137206

138207
for f in files:
139208
results = check_file(f)
@@ -142,17 +211,38 @@ def main():
142211
total += len(results)
143212
per_file_counts.append((f, len(results)))
144213

214+
escape_findings = check_raw_escapes(f)
215+
if escape_findings:
216+
print(f"\n{'=' * 70}")
217+
print(f"{f} ({len(escape_findings)} invalid escape sequence line(s))")
218+
print("=" * 70)
219+
for lineno, line, invalid in escape_findings:
220+
print(f" line {lineno}: invalid {invalid}")
221+
print(f" {line[:120]}")
222+
escape_total += len(escape_findings)
223+
escape_files += 1
224+
145225
if total:
146226
print(f"\n{'=' * 70}")
147227
print("Summary by file (sorted by mismatch count, descending):")
148228
print("=" * 70)
149229
for f, count in sorted(per_file_counts, key=lambda x: -x[1]):
150230
print(f" {count:4d} {f}")
151231
print(f"\n{total} markup mismatch(es) found across {len(per_file_counts)} file(s).")
232+
233+
if escape_total:
234+
print(
235+
f"\n{escape_total} invalid escape sequence line(s) found across "
236+
f"{escape_files} file(s). These will make msgfmt fail with "
237+
f"'invalid control sequence' - fix the offending backslash "
238+
f"in each line above."
239+
)
240+
241+
if total or escape_total:
152242
sys.exit(1)
153243

154244
print("No markup mismatches found.")
155245

156246

157247
if __name__ == "__main__":
158-
main()
248+
main()

sphinx.po

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2908,7 +2908,7 @@ msgstr ""
29082908
"پرونده‌های ورودی داده‌شده تولید می‌کند.\n"
29092909
"\n"
29102910
"قالب دایرکتیو autosummary در ماژول پایتونی\n"
2911-
"«sphinx.ext.autosummary» مستند شده و می‌توان با استفاده از موارد زیر آن را خواند::\n"
2911+
"``sphinx.ext.autosummary`` مستند شده و می‌توان با استفاده از موارد زیر آن را خواند::\n"
29122912
"\n"
29132913
" pydoc sphinx.ext.autosummary\n"
29142914

0 commit comments

Comments
 (0)