Coverage for src / ezqt_app / services / translation / _scanner.py: 89.25%

69 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-06 13:12 +0000

1# /////////////////////////////////////////////////////////////// 

2# SERVICES.TRANSLATION._SCANNER - Unified widget text scanner 

3# Project: ezqt_app 

4# /////////////////////////////////////////////////////////////// 

5 

6"""Unified widget scanner for translatable text discovery.""" 

7 

8from __future__ import annotations 

9 

10# /////////////////////////////////////////////////////////////// 

11# IMPORTS 

12# /////////////////////////////////////////////////////////////// 

13# Standard library imports 

14import re 

15from dataclasses import dataclass 

16from enum import StrEnum 

17from typing import Any 

18 

19# Third-party imports 

20from PySide6.QtCore import Qt 

21from PySide6.QtWidgets import QWidget 

22 

23# Local imports 

24from ...utils.diagnostics import warn_tech 

25 

26 

27# /////////////////////////////////////////////////////////////// 

28# TYPES 

29# /////////////////////////////////////////////////////////////// 

30class TextRole(StrEnum): 

31 """Semantic role of a translatable text property. 

32 

33 The enum value is the Qt setter method name (e.g. ``"setText"``), 

34 used by :func:`scan_widget` to detect which text attribute a widget exposes. 

35 """ 

36 

37 TEXT = "setText" 

38 TITLE = "setTitle" 

39 WINDOW_TITLE = "setWindowTitle" 

40 PLACEHOLDER = "setPlaceholderText" 

41 TOOLTIP = "setToolTip" 

42 

43 

44@dataclass(slots=True) 

45class WidgetEntry: 

46 """Binds a widget's stored original text to its semantic setter role.""" 

47 

48 original_text: str 

49 role: TextRole 

50 

51 

52# /////////////////////////////////////////////////////////////// 

53# FILTER 

54# /////////////////////////////////////////////////////////////// 

55_TECHNICAL_PREFIXES: tuple[str, ...] = ("_", "menu_", "btn_", "setting") 

56 

57_TECHNICAL_PATTERNS: tuple[re.Pattern[str], ...] = ( 

58 re.compile(r"^[A-Z_]+$"), # CONST_NAMES 

59 re.compile(r"^[a-z_]+$"), # snake_case 

60 re.compile(r"^[a-z]+[A-Z][a-z]+$"), # camelCase 

61 re.compile(r"^[A-Z][a-z]+[A-Z][a-z]+$"), # PascalCase 

62) 

63 

64 

65def is_translatable(text: str) -> bool: 

66 """Return ``True`` if *text* looks like a human-readable UI string.""" 

67 if not text or len(text) < 2: 

68 return False 

69 if text.isdigit(): 

70 return False 

71 if text.startswith(_TECHNICAL_PREFIXES): 

72 return False 

73 return not any(p.match(text) for p in _TECHNICAL_PATTERNS) 

74 

75 

76# /////////////////////////////////////////////////////////////// 

77# SCANNER 

78# /////////////////////////////////////////////////////////////// 

79def scan_widget( 

80 root: Any, 

81 *, 

82 include_tooltips: bool = True, 

83 include_placeholders: bool = True, 

84 recursive: bool = True, 

85) -> list[tuple[Any, WidgetEntry]]: 

86 """Scan *root* and return all translatable ``(widget, WidgetEntry)`` pairs. 

87 

88 Uses ``FindDirectChildrenOnly`` for O(n) DFS — avoids the O(n²) revisiting 

89 caused by the default recursive ``findChildren``. 

90 Each widget is visited exactly once (tracked by ``id``). 

91 A widget may yield multiple entries (e.g. TEXT + TOOLTIP). 

92 """ 

93 results: list[tuple[Any, WidgetEntry]] = [] 

94 seen: set[int] = set() 

95 

96 def _scan(w: Any) -> None: 

97 wid = id(w) 

98 if wid in seen: 

99 return 

100 seen.add(wid) 

101 

102 try: 

103 if hasattr(w, "text") and callable(getattr(w, "text", None)): 

104 try: 

105 text = w.text().strip() 

106 if is_translatable(text): 

107 results.append( 

108 (w, WidgetEntry(original_text=text, role=TextRole.TEXT)) 

109 ) 

110 except Exception as e: 

111 warn_tech( 

112 code="translation.scanner.read_text_failed", 

113 message="Could not read widget text", 

114 error=e, 

115 ) 

116 

117 if ( 

118 include_tooltips 

119 and hasattr(w, "toolTip") 

120 and callable(getattr(w, "toolTip", None)) 

121 ): 

122 try: 

123 tooltip = w.toolTip().strip() 

124 if is_translatable(tooltip): 

125 results.append( 

126 ( 

127 w, 

128 WidgetEntry( 

129 original_text=tooltip, role=TextRole.TOOLTIP 

130 ), 

131 ) 

132 ) 

133 except Exception as e: 

134 warn_tech( 

135 code="translation.scanner.read_tooltip_failed", 

136 message="Could not read widget toolTip", 

137 error=e, 

138 ) 

139 

140 if ( 

141 include_placeholders 

142 and hasattr(w, "placeholderText") 

143 and callable(getattr(w, "placeholderText", None)) 

144 ): 

145 try: 

146 placeholder = w.placeholderText().strip() 

147 if is_translatable(placeholder): 

148 results.append( 

149 ( 

150 w, 

151 WidgetEntry( 

152 original_text=placeholder, role=TextRole.PLACEHOLDER 

153 ), 

154 ) 

155 ) 

156 except Exception as e: 

157 warn_tech( 

158 code="translation.scanner.read_placeholder_failed", 

159 message="Could not read widget placeholderText", 

160 error=e, 

161 ) 

162 

163 if recursive: 

164 try: 

165 for child in w.findChildren( 

166 QWidget, options=Qt.FindChildOption.FindDirectChildrenOnly 

167 ): 

168 _scan(child) 

169 except Exception as e: 

170 warn_tech( 

171 code="translation.scanner.iter_children_failed", 

172 message="Could not iterate widget children", 

173 error=e, 

174 ) 

175 

176 except Exception as e: 

177 warn_tech( 

178 code="translation.scanner.widget_failed", 

179 message=f"Error scanning widget {type(w)}", 

180 error=e, 

181 ) 

182 

183 _scan(root) 

184 return results 

185 

186 

187# /////////////////////////////////////////////////////////////// 

188# PUBLIC API 

189# /////////////////////////////////////////////////////////////// 

190__all__ = ["TextRole", "WidgetEntry", "is_translatable", "scan_widget"]