Skip to content

Commit 096676e

Browse files
otAAAhclaude
andcommitted
Report line number and source references for forbidden HTML tags
When the syncer encounters a forbidden HTML tag in a .po/.pot file, the error message now includes the absolute file line number and the surrounding `#: path:lineno` source references, so translators can locate the offending string directly instead of grepping the file. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 221b20f commit 096676e

6 files changed

Lines changed: 153 additions & 27 deletions

File tree

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import re
2+
from dataclasses import dataclass
23

34
# keep in sync with tests/pylint/checker_localization.py:HTMLTagsChecker
45
_TAG_PATTERN = re.compile("<.*?>")
@@ -7,12 +8,18 @@
78
)
89

910

10-
def forbidden_tags(text: str) -> set[str]:
11-
return {
12-
tag
13-
for tag in re.findall(
14-
_TAG_PATTERN,
15-
text,
11+
@dataclass(frozen=True)
12+
class ForbiddenTag:
13+
line: int
14+
tag: str
15+
16+
17+
def forbidden_tags(text: str) -> frozenset[ForbiddenTag]:
18+
return frozenset(
19+
ForbiddenTag(
20+
line=text.count("\n", 0, match.start()) + 1,
21+
tag=match.group(),
1622
)
17-
if not re.match(_ALLOWED_TAGS_PATTERN, tag)
18-
}
23+
for match in _TAG_PATTERN.finditer(text)
24+
if not _ALLOWED_TAGS_PATTERN.match(match.group())
25+
)

src/checkmk_weblate_syncer/portable_object.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,53 @@
11
import re
2+
from collections.abc import Iterable
23
from pathlib import Path
34

5+
from .html_tags import ForbiddenTag
46

5-
def remove_header(portable_object_content: str) -> str:
6-
pattern = re.compile(r"^#: .*?:\d+$")
7+
_SOURCE_REFERENCE_PATTERN = re.compile(r"^#: (.+?:\d+)$")
8+
9+
10+
def remove_header(portable_object_content: str) -> tuple[str, int]:
711
lines = portable_object_content.splitlines()
812
index_first_source_string_location = 0
913
for index, line in enumerate(lines):
10-
if re.match(pattern, line):
14+
if _SOURCE_REFERENCE_PATTERN.match(line):
1115
index_first_source_string_location = index
1216
break
13-
return "\n".join(lines[index_first_source_string_location:]) + (
17+
body = "\n".join(lines[index_first_source_string_location:]) + (
1418
"\n" if portable_object_content.endswith("\n") else ""
1519
)
20+
return body, index_first_source_string_location
21+
22+
23+
def source_references_at(content: str, line: int) -> tuple[str, ...]:
24+
lines = content.splitlines()
25+
if not 1 <= line <= len(lines):
26+
return ()
27+
block_start = 0
28+
for i in range(line - 1, -1, -1):
29+
if lines[i] == "":
30+
block_start = i + 1
31+
break
32+
return tuple(
33+
match.group(1)
34+
for line_text in lines[block_start:line]
35+
if (match := _SOURCE_REFERENCE_PATTERN.match(line_text))
36+
)
37+
38+
39+
def format_forbidden_tags_error(
40+
portable_object_content: str,
41+
header_line_count: int,
42+
tags: Iterable[ForbiddenTag],
43+
) -> str:
44+
formatted: list[str] = []
45+
for tag in sorted(tags, key=lambda t: (t.line, t.tag)):
46+
absolute_line = header_line_count + tag.line
47+
references = source_references_at(portable_object_content, absolute_line)
48+
suffix = f" ({', '.join(references)})" if references else ""
49+
formatted.append(f" line {absolute_line}: {tag.tag!r}{suffix}")
50+
return "Found forbidden HTML tags:\n" + "\n".join(formatted)
1651

1752

1853
def make_soure_string_locations_relative(

src/checkmk_weblate_syncer/update_sources.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
from .git import commit_and_push_files, repository_in_clean_state
66
from .html_tags import forbidden_tags
77
from .logger import LOGGER
8-
from .portable_object import make_soure_string_locations_relative, remove_header
8+
from .portable_object import (
9+
format_forbidden_tags_error,
10+
make_soure_string_locations_relative,
11+
remove_header,
12+
)
913

1014

1115
def run(config: UpdateSourcesConfig) -> int:
@@ -32,11 +36,15 @@ def run(config: UpdateSourcesConfig) -> int:
3236
raise
3337

3438
LOGGER.info("Checking HTML tags")
35-
if forbidden_html_tags := forbidden_tags(remove_header(pot_file_content)):
36-
error_msg = (
37-
f"Found forbidden HTML tags: {', '.join(sorted(forbidden_html_tags))}"
39+
body, header_line_count = remove_header(pot_file_content)
40+
if forbidden_html_tags := forbidden_tags(body):
41+
raise ValueError(
42+
format_forbidden_tags_error(
43+
pot_file_content,
44+
header_line_count,
45+
forbidden_html_tags,
46+
),
3847
)
39-
raise ValueError(error_msg)
4048

4149
LOGGER.info("Making source string locations relative")
4250
pot_file_content = make_soure_string_locations_relative(

src/checkmk_weblate_syncer/update_translations.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .html_tags import forbidden_tags
1212
from .logger import LOGGER
1313
from .portable_object import (
14+
format_forbidden_tags_error,
1415
remove_header,
1516
remove_last_translator,
1617
remove_source_string_locations,
@@ -116,9 +117,14 @@ def _process_po_file_pair(
116117
)
117118

118119
LOGGER.info("Checking HTML tags")
119-
if forbidden_html_tags := forbidden_tags(remove_header(po_file_content)):
120+
body, header_line_count = remove_header(po_file_content)
121+
if forbidden_html_tags := forbidden_tags(body):
120122
return _Failure(
121-
error_message=f"Found forbidden HTML tags: {', '.join(sorted(forbidden_html_tags))}",
123+
error_message=format_forbidden_tags_error(
124+
po_file_content,
125+
header_line_count,
126+
forbidden_html_tags,
127+
),
122128
path=locale_po_file,
123129
)
124130

tests/test_html_tags.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pytest
22

3-
from checkmk_weblate_syncer.html_tags import forbidden_tags
3+
from checkmk_weblate_syncer.html_tags import ForbiddenTag, forbidden_tags
44

55

66
@pytest.mark.parametrize(
@@ -25,9 +25,25 @@
2525
pytest.param(
2626
"123 <script>injection</script>",
2727
frozenset(
28-
["<script>", "</script>"],
28+
{
29+
ForbiddenTag(line=1, tag="<script>"),
30+
ForbiddenTag(line=1, tag="</script>"),
31+
},
2932
),
3033
),
34+
pytest.param(
35+
"ok line\nbad <foo> line\nanother bad <bar> line",
36+
frozenset(
37+
{
38+
ForbiddenTag(line=2, tag="<foo>"),
39+
ForbiddenTag(line=3, tag="<bar>"),
40+
},
41+
),
42+
),
43+
pytest.param(
44+
'"<br><b>Hinweis</>: trailing"',
45+
frozenset({ForbiddenTag(line=1, tag="</>")}),
46+
),
3147
pytest.param(
3248
# pylint: disable=line-too-long
3349
"""#: /home/weblate/checkmk_weblate_sync/git/checkmk/cmk/gui/wato/pages/host_rename.py:640
@@ -54,6 +70,6 @@
5470
)
5571
def test_html_tags_checker(
5672
text: str,
57-
expected_result: frozenset[str],
73+
expected_result: frozenset[ForbiddenTag],
5874
) -> None:
5975
assert forbidden_tags(text) == expected_result

tests/test_portable_object.py

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
from pathlib import Path
22

3+
from checkmk_weblate_syncer.html_tags import ForbiddenTag
34
from checkmk_weblate_syncer.portable_object import (
5+
format_forbidden_tags_error,
46
make_soure_string_locations_relative,
57
remove_header,
68
remove_last_translator,
79
remove_source_string_locations,
10+
source_references_at,
811
)
912

1013

1114
def test_remove_header() -> None:
12-
assert (
13-
remove_header(
14-
"""# Copyright (C) 2019 Checkmk GmbH - License: GNU General Public License v2
15+
body, header_line_count = remove_header(
16+
"""# Copyright (C) 2019 Checkmk GmbH - License: GNU General Public License v2
1517
# This file is part of Checkmk (https://checkmk.com). It is subject to the terms and
1618
# conditions defined in the file COPYING, which is part of this source code package.
1719
@@ -46,8 +48,10 @@ def test_remove_header() -> None:
4648
msgid " (Duration: %s)"
4749
msgstr ""
4850
""",
49-
)
50-
# pylint: disable=line-too-long
51+
)
52+
# pylint: disable=line-too-long
53+
assert (
54+
body
5155
== """#: /home/weblate/checkmk_weblate_sync/git/checkmk/cmk/gui/wato/pages/host_rename.py:640
5256
#, python-format
5357
msgid " (%d times)"
@@ -67,6 +71,56 @@ def test_remove_header() -> None:
6771
msgstr ""
6872
"""
6973
)
74+
expected_header_line_count = 27
75+
assert header_line_count == expected_header_line_count
76+
77+
78+
def test_source_references_at() -> None:
79+
content = """#: cmk/gui/foo.py:10
80+
#: cmk/gui/bar.py:20
81+
msgid "hello"
82+
msgstr "hallo"
83+
84+
#: cmk/gui/baz.py:30
85+
msgid "world"
86+
msgstr "welt"
87+
"""
88+
# msgstr "hallo" is on line 4 → both refs from the first block
89+
assert source_references_at(content, 4) == (
90+
"cmk/gui/foo.py:10",
91+
"cmk/gui/bar.py:20",
92+
)
93+
# msgstr "welt" is on line 8 → only the ref from the second block
94+
assert source_references_at(content, 8) == ("cmk/gui/baz.py:30",)
95+
# out-of-range line returns empty
96+
assert source_references_at(content, 999) == ()
97+
98+
99+
def test_format_forbidden_tags_error() -> None:
100+
content = """# header line
101+
# header line
102+
103+
#: cmk/gui/foo.py:10
104+
#: cmk/gui/bar.py:20
105+
msgid "hello"
106+
msgstr "<bad>oops</bad>"
107+
"""
108+
# body starts at file line 4 (1-indexed); header_line_count = 3
109+
error = format_forbidden_tags_error(
110+
content,
111+
header_line_count=3,
112+
tags=frozenset(
113+
{
114+
ForbiddenTag(line=4, tag="<bad>"),
115+
ForbiddenTag(line=4, tag="</bad>"),
116+
},
117+
),
118+
)
119+
assert error == (
120+
"Found forbidden HTML tags:\n"
121+
" line 7: '</bad>' (cmk/gui/foo.py:10, cmk/gui/bar.py:20)\n"
122+
" line 7: '<bad>' (cmk/gui/foo.py:10, cmk/gui/bar.py:20)"
123+
)
70124

71125

72126
def test_make_soure_string_locations_relative() -> None:

0 commit comments

Comments
 (0)