Coverage for src / ezxl / gui / _protocols.py: 100%

47 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-29 22:41 +0000

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

2# _protocols - Abstract backend contracts for the gui layer 

3# Project: EzXl 

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

5 

6""" 

7Abstract backend contracts for the ``ezxl.gui`` layer. 

8 

9Defines six :class:`abc.ABC` base classes that each GUI backend must 

10implement. Keeping these contracts in a separate module that imports 

11**only** from the standard library prevents circular imports — proxy 

12modules may freely import these ABCs without pulling in any ``ezxl`` 

13internals. 

14 

15Backends 

16-------- 

17- :class:`AbstractRibbonBackend` — MSO ribbon command execution and state 

18 queries. 

19- :class:`AbstractMenuBackend` — Legacy CommandBar traversal and control 

20 execution. 

21- :class:`AbstractDialogBackend` — File-open, file-save, and message-box 

22 dialogs. 

23- :class:`AbstractKeysBackend` — Keystroke injection (``SendKeys``). 

24- :class:`AbstractBackstageFileOps` — Excel Backstage file operations 

25 (save, save_as, open_file, close_workbook) via COM. 

26- :class:`AbstractBackstageNavigator` — Excel Backstage visual navigation 

27 (open_options, open_save_as_panel, open_file, close_workbook) via UIA. 

28 

29Compatibility alias 

30------------------- 

31:data:`AbstractBackstageBackend` is kept as a type alias for 

32:class:`AbstractBackstageFileOps` to preserve existing imports and 

33public-API declarations. New code should reference 

34:class:`AbstractBackstageFileOps` or :class:`AbstractBackstageNavigator` 

35directly. 

36 

37Usage:: 

38 

39 from ezxl.gui._protocols import AbstractRibbonBackend 

40 

41 class MyRibbonBackend(AbstractRibbonBackend): 

42 def execute(self, mso_id: str) -> None: ... 

43 def is_enabled(self, mso_id: str) -> bool: ... 

44 def is_pressed(self, mso_id: str) -> bool: ... 

45 def is_visible(self, mso_id: str) -> bool: ... 

46""" 

47 

48from __future__ import annotations 

49 

50# /////////////////////////////////////////////////////////////// 

51# IMPORTS 

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

53# Standard library imports 

54from abc import ABC, abstractmethod 

55 

56# /////////////////////////////////////////////////////////////// 

57# CLASSES 

58# /////////////////////////////////////////////////////////////// 

59 

60 

61class AbstractRibbonBackend(ABC): 

62 """Contract for ribbon command execution and state queries. 

63 

64 Any class that implements this interface can be injected into 

65 :class:`~ezxl.gui.GUIProxy` as the ribbon backend, replacing the 

66 default COM-based implementation. 

67 

68 Implementations are responsible for thread-safety; the caller 

69 (:class:`~ezxl.gui.GUIProxy`) does **not** perform additional 

70 thread checks. 

71 """ 

72 

73 # /////////////////////////////////////////////////////////////// 

74 # ABSTRACT METHODS 

75 # /////////////////////////////////////////////////////////////// 

76 

77 @abstractmethod 

78 def execute(self, mso_id: str) -> None: 

79 """Execute a built-in ribbon command by its MSO identifier. 

80 

81 Args: 

82 mso_id: MSO control identifier string 

83 (e.g. ``"FileSave"``, ``"Copy"``, ``"PasteValues"``). 

84 

85 Raises: 

86 ExcelThreadViolationError: If called from the wrong thread. 

87 GUIOperationError: If the MSO ID is unknown or the command 

88 cannot be executed in the current application state. 

89 """ 

90 ... 

91 

92 @abstractmethod 

93 def is_enabled(self, mso_id: str) -> bool: 

94 """Return whether a ribbon command is currently enabled. 

95 

96 Args: 

97 mso_id: MSO control identifier string. 

98 

99 Returns: 

100 bool: ``True`` if the command is enabled; ``False`` otherwise. 

101 

102 Raises: 

103 ExcelThreadViolationError: If called from the wrong thread. 

104 GUIOperationError: If the MSO ID is unknown or the query fails. 

105 """ 

106 ... 

107 

108 @abstractmethod 

109 def is_pressed(self, mso_id: str) -> bool: 

110 """Return whether a ribbon toggle command is currently pressed. 

111 

112 Args: 

113 mso_id: MSO control identifier string. 

114 

115 Returns: 

116 bool: ``True`` if the toggle command is in the pressed/active 

117 state; ``False`` if not pressed or if the control does not 

118 support the pressed-state query. 

119 

120 Raises: 

121 ExcelThreadViolationError: If called from the wrong thread. 

122 """ 

123 ... 

124 

125 @abstractmethod 

126 def is_visible(self, mso_id: str) -> bool: 

127 """Return whether a ribbon command is currently visible. 

128 

129 Args: 

130 mso_id: MSO control identifier string. 

131 

132 Returns: 

133 bool: ``True`` if the command is visible in the current ribbon 

134 state; ``False`` otherwise. 

135 

136 Raises: 

137 ExcelThreadViolationError: If called from the wrong thread. 

138 GUIOperationError: If the MSO ID is unknown or the query fails. 

139 """ 

140 ... 

141 

142 

143class AbstractMenuBackend(ABC): 

144 """Contract for legacy CommandBar traversal and control execution. 

145 

146 Any class that implements this interface can be injected into 

147 :class:`~ezxl.gui.GUIProxy` as the menu backend, replacing the 

148 default COM-based implementation. 

149 """ 

150 

151 # /////////////////////////////////////////////////////////////// 

152 # ABSTRACT METHODS 

153 # /////////////////////////////////////////////////////////////// 

154 

155 @abstractmethod 

156 def click(self, bar_name: str, *item_path: str) -> None: 

157 """Traverse a CommandBar by caption path and execute the final control. 

158 

159 Args: 

160 bar_name: The name of the CommandBar to start from. 

161 *item_path: One or more control captions forming the path to 

162 the target control. At least one caption is required. 

163 

164 Raises: 

165 ExcelThreadViolationError: If called from the wrong thread. 

166 GUIOperationError: If the bar, any intermediate control, or 

167 the final control cannot be found, or if execution fails. 

168 """ 

169 ... 

170 

171 @abstractmethod 

172 def list_bars(self) -> list[str]: 

173 """Return the names of all CommandBars registered with Excel. 

174 

175 Returns: 

176 list[str]: Sorted list of CommandBar names. 

177 

178 Raises: 

179 ExcelThreadViolationError: If called from the wrong thread. 

180 GUIOperationError: If the COM collection cannot be iterated. 

181 """ 

182 ... 

183 

184 @abstractmethod 

185 def list_controls(self, bar_name: str) -> list[str]: 

186 """Return the captions of all top-level controls in a CommandBar. 

187 

188 Args: 

189 bar_name: The name of the CommandBar to inspect. 

190 

191 Returns: 

192 list[str]: Captions of the bar's top-level controls, in order. 

193 

194 Raises: 

195 ExcelThreadViolationError: If called from the wrong thread. 

196 GUIOperationError: If the bar cannot be found or the controls 

197 collection cannot be iterated. 

198 """ 

199 ... 

200 

201 

202class AbstractDialogBackend(ABC): 

203 """Contract for file-open, file-save, and message-box dialogs. 

204 

205 Any class that implements this interface can be injected into 

206 :class:`~ezxl.gui.GUIProxy` as the dialog backend, replacing the 

207 default COM/Win32-based implementation. 

208 

209 Default parameter values in implementing methods must match those 

210 declared on :class:`~ezxl.gui.DialogProxy`. 

211 """ 

212 

213 # /////////////////////////////////////////////////////////////// 

214 # ABSTRACT METHODS 

215 # /////////////////////////////////////////////////////////////// 

216 

217 @abstractmethod 

218 def get_file_open( 

219 self, 

220 title: str = "Open", 

221 initial_dir: str | None = None, 

222 filter: str = "Excel Files (*.xls*), *.xls*", 

223 ) -> str | None: 

224 """Show a file-open picker dialog and return the selected path. 

225 

226 Args: 

227 title: Dialog title bar text. Defaults to ``"Open"``. 

228 initial_dir: Directory to open the dialog in. If ``None``, 

229 the backend chooses the initial directory. 

230 filter: File-type filter string. Defaults to Excel files. 

231 

232 Returns: 

233 str | None: Absolute path chosen by the user, or ``None`` 

234 if the dialog was cancelled. 

235 

236 Raises: 

237 ExcelThreadViolationError: If called from the wrong thread. 

238 GUIOperationError: If the underlying call fails. 

239 """ 

240 ... 

241 

242 @abstractmethod 

243 def get_file_save( 

244 self, 

245 title: str = "Save As", 

246 initial_dir: str | None = None, 

247 filter: str = "Excel Files (*.xlsx), *.xlsx", 

248 ) -> str | None: 

249 """Show a file-save picker dialog and return the selected path. 

250 

251 Args: 

252 title: Dialog title bar text. Defaults to ``"Save As"``. 

253 initial_dir: Directory to open the dialog in. If ``None``, 

254 the backend chooses the initial directory. 

255 filter: File-type filter string. Defaults to ``.xlsx``. 

256 

257 Returns: 

258 str | None: Absolute path chosen by the user, or ``None`` 

259 if the dialog was cancelled. 

260 

261 Raises: 

262 ExcelThreadViolationError: If called from the wrong thread. 

263 GUIOperationError: If the underlying call fails. 

264 """ 

265 ... 

266 

267 @abstractmethod 

268 def alert(self, message: str, title: str = "EzXl") -> None: 

269 """Display a modal information message box. 

270 

271 Args: 

272 message: The body text displayed in the message box. 

273 title: Caption for the message box title bar. 

274 Defaults to ``"EzXl"``. 

275 

276 Raises: 

277 ExcelThreadViolationError: If called from the wrong thread. 

278 GUIOperationError: If the underlying call fails. 

279 """ 

280 ... 

281 

282 

283class AbstractKeysBackend(ABC): 

284 """Contract for keystroke injection into Excel. 

285 

286 Any class that implements this interface can be injected into 

287 :class:`~ezxl.gui.GUIProxy` as the keys backend, replacing the 

288 default COM-based implementation. 

289 """ 

290 

291 # /////////////////////////////////////////////////////////////// 

292 # ABSTRACT METHODS 

293 # /////////////////////////////////////////////////////////////// 

294 

295 @abstractmethod 

296 def send_keys(self, keys: str, wait: bool = True) -> None: 

297 """Send a keystroke sequence to the Excel Application window. 

298 

299 Args: 

300 keys: Keystroke string in VBA SendKeys notation 

301 (e.g. ``"{ENTER}"``, ``"^s"`` for Ctrl+S). 

302 wait: If ``True``, block until Excel processes the keystrokes 

303 before returning. Defaults to ``True``. 

304 

305 Raises: 

306 ExcelThreadViolationError: If called from the wrong thread. 

307 COMOperationError: If the keystroke injection call fails. 

308 """ 

309 ... 

310 

311 

312class AbstractBackstageFileOps(ABC): 

313 """Contract for Excel Backstage file operations via COM. 

314 

315 Covers the four operations that the COM object model executes 

316 reliably, focus-independently, and locale-independently: 

317 ``save``, ``save_as``, ``open_file``, and ``close_workbook``. 

318 

319 This contract is implemented by 

320 :class:`~ezxl.gui.win32com.COMBackstageBackend`. Inject it into 

321 :class:`~ezxl.gui.GUIProxy` via the *backstage* parameter:: 

322 

323 gui = GUIProxy(xl, backstage=COMBackstageBackend(xl)) 

324 """ 

325 

326 # /////////////////////////////////////////////////////////////// 

327 # ABSTRACT METHODS 

328 # /////////////////////////////////////////////////////////////// 

329 

330 @abstractmethod 

331 def save(self) -> None: 

332 """Save the active workbook. 

333 

334 Raises: 

335 ExcelThreadViolationError: If called from the wrong thread. 

336 WorkbookNotFoundError: If no workbook is currently open. 

337 GUIOperationError: If the action cannot be completed. 

338 """ 

339 ... 

340 

341 @abstractmethod 

342 def save_as(self, path: str | None = None) -> None: 

343 """Save the active workbook under a new path, or open the Save As dialog. 

344 

345 Args: 

346 path: Absolute path for the new file, including extension. 

347 If ``None``, the built-in Save As dialog is displayed for 

348 manual path selection. 

349 

350 Raises: 

351 ExcelThreadViolationError: If called from the wrong thread. 

352 WorkbookNotFoundError: If no workbook is currently open. 

353 GUIOperationError: If the panel cannot be opened or path entry 

354 fails. 

355 """ 

356 ... 

357 

358 @abstractmethod 

359 def open_file(self) -> None: 

360 """Show the built-in Excel Open dialog. 

361 

362 Raises: 

363 ExcelThreadViolationError: If called from the wrong thread. 

364 GUIOperationError: If the dialog cannot be opened. 

365 """ 

366 ... 

367 

368 @abstractmethod 

369 def close_workbook(self) -> None: 

370 """Close the active workbook without saving. 

371 

372 Raises: 

373 ExcelThreadViolationError: If called from the wrong thread. 

374 WorkbookNotFoundError: If no workbook is currently open. 

375 GUIOperationError: If the action cannot be completed. 

376 """ 

377 ... 

378 

379 

380class AbstractBackstageNavigator(ABC): 

381 """Contract for Excel Backstage visual navigation via UI Automation. 

382 

383 Covers operations that require UIA-level interaction with the 

384 Backstage overlay: navigating to the Options panel, opening the 

385 Save As panel without confirming, and UIA-driven open/close actions. 

386 

387 This contract is implemented by 

388 :class:`~ezxl.gui.pywinauto.PywinautoBackstageBackend`. Inject it 

389 into :class:`~ezxl.gui.GUIProxy` via the *backstage_nav* parameter:: 

390 

391 gui = GUIProxy( 

392 xl, 

393 backstage=COMBackstageBackend(xl), 

394 backstage_nav=PywinautoBackstageBackend(hwnd=xl.hwnd, locale="fr"), 

395 ) 

396 

397 Note: 

398 ``backstage_nav`` is optional — :class:`~ezxl.gui.GUIProxy` defaults 

399 it to ``None``. Access it via ``xl.gui.backstage_nav``; guard with 

400 an ``is not None`` check before calling if it may be absent. 

401 """ 

402 

403 # /////////////////////////////////////////////////////////////// 

404 # ABSTRACT METHODS 

405 # /////////////////////////////////////////////////////////////// 

406 

407 @abstractmethod 

408 def open_options(self) -> None: 

409 """Navigate to the Excel Options panel via the Backstage. 

410 

411 Raises: 

412 GUIOperationError: If the Options panel cannot be reached. 

413 """ 

414 ... 

415 

416 @abstractmethod 

417 def open_save_as_panel(self) -> None: 

418 """Open the Save As panel in the Backstage without confirming a save. 

419 

420 Clicks the ``"Enregistrer sous"`` (or locale equivalent) ListItem 

421 and leaves the panel open for manual interaction. 

422 

423 Raises: 

424 GUIOperationError: If the panel cannot be reached. 

425 """ 

426 ... 

427 

428 @abstractmethod 

429 def open_file(self) -> None: 

430 """Open the Open panel via the Backstage. 

431 

432 Raises: 

433 GUIOperationError: If the panel cannot be reached. 

434 """ 

435 ... 

436 

437 @abstractmethod 

438 def close_workbook(self) -> None: 

439 """Close the active workbook via a Backstage UIA click. 

440 

441 Raises: 

442 GUIOperationError: If the action cannot be completed. 

443 """ 

444 ... 

445 

446 

447# /////////////////////////////////////////////////////////////// 

448# COMPATIBILITY ALIAS 

449# /////////////////////////////////////////////////////////////// 

450 

451#: Deprecated alias for :class:`AbstractBackstageFileOps`. 

452#: 

453#: Kept so that existing code importing ``AbstractBackstageBackend`` from 

454#: ``ezxl`` or ``ezxl.gui._protocols`` continues to work without change. 

455#: New code should reference :class:`AbstractBackstageFileOps` directly. 

456AbstractBackstageBackend = AbstractBackstageFileOps