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
« 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# ///////////////////////////////////////////////////////////////
6"""
7Abstract backend contracts for the ``ezxl.gui`` layer.
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.
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.
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.
37Usage::
39 from ezxl.gui._protocols import AbstractRibbonBackend
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"""
48from __future__ import annotations
50# ///////////////////////////////////////////////////////////////
51# IMPORTS
52# ///////////////////////////////////////////////////////////////
53# Standard library imports
54from abc import ABC, abstractmethod
56# ///////////////////////////////////////////////////////////////
57# CLASSES
58# ///////////////////////////////////////////////////////////////
61class AbstractRibbonBackend(ABC):
62 """Contract for ribbon command execution and state queries.
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.
68 Implementations are responsible for thread-safety; the caller
69 (:class:`~ezxl.gui.GUIProxy`) does **not** perform additional
70 thread checks.
71 """
73 # ///////////////////////////////////////////////////////////////
74 # ABSTRACT METHODS
75 # ///////////////////////////////////////////////////////////////
77 @abstractmethod
78 def execute(self, mso_id: str) -> None:
79 """Execute a built-in ribbon command by its MSO identifier.
81 Args:
82 mso_id: MSO control identifier string
83 (e.g. ``"FileSave"``, ``"Copy"``, ``"PasteValues"``).
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 ...
92 @abstractmethod
93 def is_enabled(self, mso_id: str) -> bool:
94 """Return whether a ribbon command is currently enabled.
96 Args:
97 mso_id: MSO control identifier string.
99 Returns:
100 bool: ``True`` if the command is enabled; ``False`` otherwise.
102 Raises:
103 ExcelThreadViolationError: If called from the wrong thread.
104 GUIOperationError: If the MSO ID is unknown or the query fails.
105 """
106 ...
108 @abstractmethod
109 def is_pressed(self, mso_id: str) -> bool:
110 """Return whether a ribbon toggle command is currently pressed.
112 Args:
113 mso_id: MSO control identifier string.
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.
120 Raises:
121 ExcelThreadViolationError: If called from the wrong thread.
122 """
123 ...
125 @abstractmethod
126 def is_visible(self, mso_id: str) -> bool:
127 """Return whether a ribbon command is currently visible.
129 Args:
130 mso_id: MSO control identifier string.
132 Returns:
133 bool: ``True`` if the command is visible in the current ribbon
134 state; ``False`` otherwise.
136 Raises:
137 ExcelThreadViolationError: If called from the wrong thread.
138 GUIOperationError: If the MSO ID is unknown or the query fails.
139 """
140 ...
143class AbstractMenuBackend(ABC):
144 """Contract for legacy CommandBar traversal and control execution.
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 """
151 # ///////////////////////////////////////////////////////////////
152 # ABSTRACT METHODS
153 # ///////////////////////////////////////////////////////////////
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.
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.
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 ...
171 @abstractmethod
172 def list_bars(self) -> list[str]:
173 """Return the names of all CommandBars registered with Excel.
175 Returns:
176 list[str]: Sorted list of CommandBar names.
178 Raises:
179 ExcelThreadViolationError: If called from the wrong thread.
180 GUIOperationError: If the COM collection cannot be iterated.
181 """
182 ...
184 @abstractmethod
185 def list_controls(self, bar_name: str) -> list[str]:
186 """Return the captions of all top-level controls in a CommandBar.
188 Args:
189 bar_name: The name of the CommandBar to inspect.
191 Returns:
192 list[str]: Captions of the bar's top-level controls, in order.
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 ...
202class AbstractDialogBackend(ABC):
203 """Contract for file-open, file-save, and message-box dialogs.
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.
209 Default parameter values in implementing methods must match those
210 declared on :class:`~ezxl.gui.DialogProxy`.
211 """
213 # ///////////////////////////////////////////////////////////////
214 # ABSTRACT METHODS
215 # ///////////////////////////////////////////////////////////////
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.
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.
232 Returns:
233 str | None: Absolute path chosen by the user, or ``None``
234 if the dialog was cancelled.
236 Raises:
237 ExcelThreadViolationError: If called from the wrong thread.
238 GUIOperationError: If the underlying call fails.
239 """
240 ...
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.
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``.
257 Returns:
258 str | None: Absolute path chosen by the user, or ``None``
259 if the dialog was cancelled.
261 Raises:
262 ExcelThreadViolationError: If called from the wrong thread.
263 GUIOperationError: If the underlying call fails.
264 """
265 ...
267 @abstractmethod
268 def alert(self, message: str, title: str = "EzXl") -> None:
269 """Display a modal information message box.
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"``.
276 Raises:
277 ExcelThreadViolationError: If called from the wrong thread.
278 GUIOperationError: If the underlying call fails.
279 """
280 ...
283class AbstractKeysBackend(ABC):
284 """Contract for keystroke injection into Excel.
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 """
291 # ///////////////////////////////////////////////////////////////
292 # ABSTRACT METHODS
293 # ///////////////////////////////////////////////////////////////
295 @abstractmethod
296 def send_keys(self, keys: str, wait: bool = True) -> None:
297 """Send a keystroke sequence to the Excel Application window.
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``.
305 Raises:
306 ExcelThreadViolationError: If called from the wrong thread.
307 COMOperationError: If the keystroke injection call fails.
308 """
309 ...
312class AbstractBackstageFileOps(ABC):
313 """Contract for Excel Backstage file operations via COM.
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``.
319 This contract is implemented by
320 :class:`~ezxl.gui.win32com.COMBackstageBackend`. Inject it into
321 :class:`~ezxl.gui.GUIProxy` via the *backstage* parameter::
323 gui = GUIProxy(xl, backstage=COMBackstageBackend(xl))
324 """
326 # ///////////////////////////////////////////////////////////////
327 # ABSTRACT METHODS
328 # ///////////////////////////////////////////////////////////////
330 @abstractmethod
331 def save(self) -> None:
332 """Save the active workbook.
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 ...
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.
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.
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 ...
358 @abstractmethod
359 def open_file(self) -> None:
360 """Show the built-in Excel Open dialog.
362 Raises:
363 ExcelThreadViolationError: If called from the wrong thread.
364 GUIOperationError: If the dialog cannot be opened.
365 """
366 ...
368 @abstractmethod
369 def close_workbook(self) -> None:
370 """Close the active workbook without saving.
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 ...
380class AbstractBackstageNavigator(ABC):
381 """Contract for Excel Backstage visual navigation via UI Automation.
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.
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::
391 gui = GUIProxy(
392 xl,
393 backstage=COMBackstageBackend(xl),
394 backstage_nav=PywinautoBackstageBackend(hwnd=xl.hwnd, locale="fr"),
395 )
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 """
403 # ///////////////////////////////////////////////////////////////
404 # ABSTRACT METHODS
405 # ///////////////////////////////////////////////////////////////
407 @abstractmethod
408 def open_options(self) -> None:
409 """Navigate to the Excel Options panel via the Backstage.
411 Raises:
412 GUIOperationError: If the Options panel cannot be reached.
413 """
414 ...
416 @abstractmethod
417 def open_save_as_panel(self) -> None:
418 """Open the Save As panel in the Backstage without confirming a save.
420 Clicks the ``"Enregistrer sous"`` (or locale equivalent) ListItem
421 and leaves the panel open for manual interaction.
423 Raises:
424 GUIOperationError: If the panel cannot be reached.
425 """
426 ...
428 @abstractmethod
429 def open_file(self) -> None:
430 """Open the Open panel via the Backstage.
432 Raises:
433 GUIOperationError: If the panel cannot be reached.
434 """
435 ...
437 @abstractmethod
438 def close_workbook(self) -> None:
439 """Close the active workbook via a Backstage UIA click.
441 Raises:
442 GUIOperationError: If the action cannot be completed.
443 """
444 ...
447# ///////////////////////////////////////////////////////////////
448# COMPATIBILITY ALIAS
449# ///////////////////////////////////////////////////////////////
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