Coverage for src / ezqt_app / services / translation / _scanner.py: 89.25%
69 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-26 07:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-26 07:07 +0000
1# ///////////////////////////////////////////////////////////////
2# SERVICES.TRANSLATION._SCANNER - Unified widget text scanner
3# Project: ezqt_app
4# ///////////////////////////////////////////////////////////////
6"""Unified widget scanner for translatable text discovery."""
8from __future__ import annotations
10# ///////////////////////////////////////////////////////////////
11# IMPORTS
12# ///////////////////////////////////////////////////////////////
13# Standard library imports
14import re
15from dataclasses import dataclass
16from enum import StrEnum
17from typing import Any
19# Third-party imports
20from PySide6.QtCore import Qt
21from PySide6.QtWidgets import QWidget
23# Local imports
24from ...utils.diagnostics import warn_tech
27# ///////////////////////////////////////////////////////////////
28# TYPES
29# ///////////////////////////////////////////////////////////////
30class TextRole(StrEnum):
31 """Semantic role of a translatable text property.
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 """
37 TEXT = "setText"
38 TITLE = "setTitle"
39 WINDOW_TITLE = "setWindowTitle"
40 PLACEHOLDER = "setPlaceholderText"
41 TOOLTIP = "setToolTip"
44@dataclass(slots=True)
45class WidgetEntry:
46 """Binds a widget's stored original text to its semantic setter role."""
48 original_text: str
49 role: TextRole
52# ///////////////////////////////////////////////////////////////
53# FILTER
54# ///////////////////////////////////////////////////////////////
55_TECHNICAL_PREFIXES: tuple[str, ...] = ("_", "menu_", "btn_", "setting")
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)
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)
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.
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()
96 def _scan(w: Any) -> None:
97 wid = id(w)
98 if wid in seen:
99 return
100 seen.add(wid)
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 )
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 )
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 )
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 )
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 )
183 _scan(root)
184 return results
187# ///////////////////////////////////////////////////////////////
188# PUBLIC API
189# ///////////////////////////////////////////////////////////////
190__all__ = ["TextRole", "WidgetEntry", "is_translatable", "scan_widget"]