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

10 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-29 15:53 +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 

55from typing import Any, Protocol 

56 

57# /////////////////////////////////////////////////////////////// 

58# CLASSES 

59# /////////////////////////////////////////////////////////////// 

60 

61 

62class ExcelAppLike(Protocol): 

63 """Structural contract for ExcelApp-like objects used by GUI backends. 

64 

65 Keeps GUI typing independent from ``ezxl.core`` to preserve layer 

66 boundaries enforced by import-linter. 

67 """ 

68 

69 _thread_id: int 

70 

71 def _get_app(self) -> Any: 

72 """Return the underlying COM ``Application`` object.""" 

73 ... 

74 

75 

76class AbstractRibbonBackend(ABC): 

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

78 

79 Any class that implements this interface can be injected into 

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

81 default COM-based implementation. 

82 

83 Implementations are responsible for thread-safety; the caller 

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

85 thread checks. 

86 """ 

87 

88 # /////////////////////////////////////////////////////////////// 

89 # ABSTRACT METHODS 

90 # /////////////////////////////////////////////////////////////// 

91 

92 @abstractmethod 

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

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

95 

96 Args: 

97 mso_id: MSO control identifier string 

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

99 

100 Raises: 

101 ExcelThreadViolationError: If called from the wrong thread. 

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

103 cannot be executed in the current application state. 

104 """ 

105 ... 

106 

107 @abstractmethod 

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

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

110 

111 Args: 

112 mso_id: MSO control identifier string. 

113 

114 Returns: 

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

116 

117 Raises: 

118 ExcelThreadViolationError: If called from the wrong thread. 

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

120 """ 

121 ... 

122 

123 @abstractmethod 

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

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

126 

127 Args: 

128 mso_id: MSO control identifier string. 

129 

130 Returns: 

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

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

133 support the pressed-state query. 

134 

135 Raises: 

136 ExcelThreadViolationError: If called from the wrong thread. 

137 """ 

138 ... 

139 

140 @abstractmethod 

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

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

143 

144 Args: 

145 mso_id: MSO control identifier string. 

146 

147 Returns: 

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

149 state; ``False`` otherwise. 

150 

151 Raises: 

152 ExcelThreadViolationError: If called from the wrong thread. 

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

154 """ 

155 ... 

156 

157 

158class AbstractMenuBackend(ABC): 

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

160 

161 Any class that implements this interface can be injected into 

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

163 default COM-based implementation. 

164 """ 

165 

166 # /////////////////////////////////////////////////////////////// 

167 # ABSTRACT METHODS 

168 # /////////////////////////////////////////////////////////////// 

169 

170 @abstractmethod 

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

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

173 

174 Args: 

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

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

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

178 

179 Raises: 

180 ExcelThreadViolationError: If called from the wrong thread. 

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

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

183 """ 

184 ... 

185 

186 @abstractmethod 

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

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

189 

190 Returns: 

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

192 

193 Raises: 

194 ExcelThreadViolationError: If called from the wrong thread. 

195 GUIOperationError: If the COM collection cannot be iterated. 

196 """ 

197 ... 

198 

199 @abstractmethod 

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

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

202 

203 Args: 

204 bar_name: The name of the CommandBar to inspect. 

205 

206 Returns: 

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

208 

209 Raises: 

210 ExcelThreadViolationError: If called from the wrong thread. 

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

212 collection cannot be iterated. 

213 """ 

214 ... 

215 

216 

217class AbstractDialogBackend(ABC): 

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

219 

220 Any class that implements this interface can be injected into 

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

222 default COM/Win32-based implementation. 

223 

224 Default parameter values in implementing methods must match those 

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

226 """ 

227 

228 # /////////////////////////////////////////////////////////////// 

229 # ABSTRACT METHODS 

230 # /////////////////////////////////////////////////////////////// 

231 

232 @abstractmethod 

233 def get_file_open( 

234 self, 

235 title: str = "Open", 

236 initial_dir: str | None = None, 

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

238 ) -> str | None: 

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

240 

241 Args: 

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

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

244 the backend chooses the initial directory. 

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

246 

247 Returns: 

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

249 if the dialog was cancelled. 

250 

251 Raises: 

252 ExcelThreadViolationError: If called from the wrong thread. 

253 GUIOperationError: If the underlying call fails. 

254 """ 

255 ... 

256 

257 @abstractmethod 

258 def get_file_save( 

259 self, 

260 title: str = "Save As", 

261 initial_dir: str | None = None, 

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

263 ) -> str | None: 

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

265 

266 Args: 

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

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

269 the backend chooses the initial directory. 

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

271 

272 Returns: 

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

274 if the dialog was cancelled. 

275 

276 Raises: 

277 ExcelThreadViolationError: If called from the wrong thread. 

278 GUIOperationError: If the underlying call fails. 

279 """ 

280 ... 

281 

282 @abstractmethod 

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

284 """Display a modal information message box. 

285 

286 Args: 

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

288 title: Caption for the message box title bar. 

289 Defaults to ``"EzXl"``. 

290 

291 Raises: 

292 ExcelThreadViolationError: If called from the wrong thread. 

293 GUIOperationError: If the underlying call fails. 

294 """ 

295 ... 

296 

297 

298class AbstractKeysBackend(ABC): 

299 """Contract for keystroke injection into Excel. 

300 

301 Any class that implements this interface can be injected into 

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

303 default COM-based implementation. 

304 """ 

305 

306 # /////////////////////////////////////////////////////////////// 

307 # ABSTRACT METHODS 

308 # /////////////////////////////////////////////////////////////// 

309 

310 @abstractmethod 

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

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

313 

314 Args: 

315 keys: Keystroke string in VBA SendKeys notation 

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

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

318 before returning. Defaults to ``True``. 

319 

320 Raises: 

321 ExcelThreadViolationError: If called from the wrong thread. 

322 COMOperationError: If the keystroke injection call fails. 

323 """ 

324 ... 

325 

326 

327class AbstractBackstageFileOps(ABC): 

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

329 

330 Covers the four operations that the COM object model executes 

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

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

333 

334 This contract is implemented by 

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

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

337 

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

339 """ 

340 

341 # /////////////////////////////////////////////////////////////// 

342 # ABSTRACT METHODS 

343 # /////////////////////////////////////////////////////////////// 

344 

345 @abstractmethod 

346 def save(self) -> None: 

347 """Save the active workbook. 

348 

349 Raises: 

350 ExcelThreadViolationError: If called from the wrong thread. 

351 WorkbookNotFoundError: If no workbook is currently open. 

352 GUIOperationError: If the action cannot be completed. 

353 """ 

354 ... 

355 

356 @abstractmethod 

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

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

359 

360 Args: 

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

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

363 manual path selection. 

364 

365 Raises: 

366 ExcelThreadViolationError: If called from the wrong thread. 

367 WorkbookNotFoundError: If no workbook is currently open. 

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

369 fails. 

370 """ 

371 ... 

372 

373 @abstractmethod 

374 def open_file(self) -> None: 

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

376 

377 Raises: 

378 ExcelThreadViolationError: If called from the wrong thread. 

379 GUIOperationError: If the dialog cannot be opened. 

380 """ 

381 ... 

382 

383 @abstractmethod 

384 def close_workbook(self) -> None: 

385 """Close the active workbook without saving. 

386 

387 Raises: 

388 ExcelThreadViolationError: If called from the wrong thread. 

389 WorkbookNotFoundError: If no workbook is currently open. 

390 GUIOperationError: If the action cannot be completed. 

391 """ 

392 ... 

393 

394 

395class AbstractBackstageNavigator(ABC): 

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

397 

398 Covers operations that require UIA-level interaction with the 

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

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

401 

402 This contract is implemented by 

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

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

405 

406 gui = GUIProxy( 

407 xl, 

408 backstage=COMBackstageBackend(xl), 

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

410 ) 

411 

412 Note: 

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

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

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

416 """ 

417 

418 # /////////////////////////////////////////////////////////////// 

419 # ABSTRACT METHODS 

420 # /////////////////////////////////////////////////////////////// 

421 

422 @abstractmethod 

423 def open_options(self) -> None: 

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

425 

426 Raises: 

427 GUIOperationError: If the Options panel cannot be reached. 

428 """ 

429 ... 

430 

431 @abstractmethod 

432 def open_save_as_panel(self) -> None: 

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

434 

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

436 and leaves the panel open for manual interaction. 

437 

438 Raises: 

439 GUIOperationError: If the panel cannot be reached. 

440 """ 

441 ... 

442 

443 @abstractmethod 

444 def open_file(self) -> None: 

445 """Open the Open panel via the Backstage. 

446 

447 Raises: 

448 GUIOperationError: If the panel cannot be reached. 

449 """ 

450 ... 

451 

452 @abstractmethod 

453 def close_workbook(self) -> None: 

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

455 

456 Raises: 

457 GUIOperationError: If the action cannot be completed. 

458 """ 

459 ... 

460 

461 

462# /////////////////////////////////////////////////////////////// 

463# COMPATIBILITY ALIAS 

464# /////////////////////////////////////////////////////////////// 

465 

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

467#: 

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

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

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

471AbstractBackstageBackend = AbstractBackstageFileOps