Coverage for app/venv/lib/python3.14/site-packages/weblate/checks/fluent/parts.py: 26%
72 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
1# Copyright © Henry Wilkes <henry@torproject.org>
2#
3# SPDX-License-Identifier: GPL-3.0-or-later
5from __future__ import annotations
7import re
8from typing import TYPE_CHECKING
10from django.utils.translation import gettext, gettext_lazy
12from weblate.checks.base import TargetCheck
13from weblate.checks.fluent.utils import (
14 FluentPatterns,
15 FluentUnitConverter,
16 format_html_code,
17 format_html_error_list,
18 translation_from_check,
19)
21if TYPE_CHECKING: 21 ↛ 22line 21 didn't jump to line 22 because the condition on line 21 was never true
22 from django.utils.safestring import SafeString
23 from django_stubs_ext import StrOrPromise
24 from translate.storage.fluent import FluentPart
26 from weblate.checks.fluent.utils import CheckModel, HighlightsType, TransUnitModel
29class _PartsDifference:
30 """Represents the missing or extra parts in the target."""
32 def __init__(
33 self,
34 source_parts: list[FluentPart],
35 target_parts: list[FluentPart],
36 ) -> None:
37 # We don't expect any duplicate part names since that should raise a
38 # syntax error in the translation toolkit.
39 self._missing = [
40 part.name
41 for part in source_parts
42 if not self._has_matching_part(target_parts, part)
43 ]
44 self._extra = [
45 part.name
46 for part in target_parts
47 if not self._has_matching_part(source_parts, part)
48 ]
50 @staticmethod
51 def _has_matching_part(
52 part_list: list[FluentPart],
53 find_part: FluentPart,
54 ) -> bool:
55 return any(part.name == find_part.name for part in part_list)
57 def __bool__(self) -> bool:
58 return bool(self._missing or self._extra)
60 def description(self) -> SafeString:
61 errors = []
62 if "" in self._missing:
63 errors.append(gettext("Fluent value is empty."))
64 if "" in self._extra:
65 errors.append(gettext("Fluent value should be empty."))
66 for part_name in self._missing:
67 if not part_name:
68 continue
69 errors.append(
70 format_html_code(
71 gettext("Missing Fluent attribute: {hint}"),
72 hint=f".{part_name}\xa0=\xa0…",
73 )
74 )
75 for part_name in self._extra:
76 if not part_name:
77 continue
78 errors.append(
79 format_html_code(
80 gettext("Unexpected Fluent attribute: {hint}"),
81 hint=f".{part_name}\xa0=\xa0…",
82 )
83 )
85 return format_html_error_list(errors)
88class FluentPartsCheck(TargetCheck):
89 """
90 Check that the target has the same Fluent parts as the source.
92 Each Fluent Message can have an optional value (the main text content), and
93 optional attributes, each of which is a "part" of the Message. In Weblate,
94 all these parts appear within the same block, using Fluent-like syntax to
95 specify the attributes. For example:
97 | This is the Message value
98 | .title = This is the title attribute
99 | .alt = This is the alt attribute
101 This check ensures that the translated Message also has a value if the
102 source Message has one, or no value if the source has none. This also checks
103 that the same attributes used in the source Message also appear in the
104 translation, with no additions.
106 NOTE: This check is not applied to Fluent Terms since Terms always have a
107 value, and Term attributes tend to be locale-specific (used for grammar
108 rules, etc.), and are not expected to appear in all translations.
109 """
111 check_id = "fluent-parts"
112 name = gettext_lazy("Fluent parts")
113 description = gettext_lazy("Fluent parts should match.")
114 default_disabled = True
116 @classmethod
117 def _compare_parts(
118 cls, unit: TransUnitModel, source: str, target: str
119 ) -> _PartsDifference | None:
120 """Compare the list of parts found in the source and target."""
121 source_unit = FluentUnitConverter(unit, source)
122 if source_unit.fluent_type() == "Term":
123 # Don't want to check Terms since their attributes are
124 # locale-specific and a Term missing a value is a fluent syntax
125 # error.
126 return None
128 source_parts = source_unit.to_fluent_parts()
130 if source_parts is None:
131 # Some syntax error.
132 return None
134 target_parts = FluentUnitConverter(unit, target).to_fluent_parts()
136 if target_parts is None:
137 # Some syntax error, so don't compare.
138 return None
140 return _PartsDifference(source_parts, target_parts)
142 def check_single(
143 self,
144 source: str,
145 target: str,
146 unit: TransUnitModel,
147 ) -> bool:
148 return bool(self._compare_parts(unit, source, target))
150 def check_highlight(
151 self,
152 source: str,
153 unit: TransUnitModel,
154 ) -> HighlightsType:
155 if self.should_skip(unit): 155 ↛ 160line 155 didn't jump to line 160 because the condition on line 155 was always true
156 return []
158 # We want to highlight the attribute syntax in Messages.
160 fluent_unit = FluentUnitConverter(unit, source)
161 if fluent_unit.fluent_type() == "Term":
162 return []
164 highlight_patterns = []
165 for part in fluent_unit.to_fluent_parts() or []:
166 if not part.name:
167 # Don't highlight the value since there is no visible syntax for
168 # it.
169 continue
170 # We want to match ".attr-name =" at the line start for each
171 # attribute.
172 # The attribute name shouldn't need escaping, but we do so here for
173 # safety.
174 highlight_patterns.append(r"^ *\." + re.escape(part.name) + r" *=")
175 return FluentPatterns.highlight_source(source, highlight_patterns)
177 def get_description(self, check_model: CheckModel) -> StrOrPromise:
178 (unit, source, target) = translation_from_check(check_model)
179 difference = self._compare_parts(unit, source, target)
180 if not difference:
181 return super().get_description(check_model)
183 return difference.description()