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
« 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# ///////////////////////////////////////////////////////////////
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
55from typing import Any, Protocol
57# ///////////////////////////////////////////////////////////////
58# CLASSES
59# ///////////////////////////////////////////////////////////////
62class ExcelAppLike(Protocol):
63 """Structural contract for ExcelApp-like objects used by GUI backends.
65 Keeps GUI typing independent from ``ezxl.core`` to preserve layer
66 boundaries enforced by import-linter.
67 """
69 _thread_id: int
71 def _get_app(self) -> Any:
72 """Return the underlying COM ``Application`` object."""
73 ...
76class AbstractRibbonBackend(ABC):
77 """Contract for ribbon command execution and state queries.
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.
83 Implementations are responsible for thread-safety; the caller
84 (:class:`~ezxl.gui.GUIProxy`) does **not** perform additional
85 thread checks.
86 """
88 # ///////////////////////////////////////////////////////////////
89 # ABSTRACT METHODS
90 # ///////////////////////////////////////////////////////////////
92 @abstractmethod
93 def execute(self, mso_id: str) -> None:
94 """Execute a built-in ribbon command by its MSO identifier.
96 Args:
97 mso_id: MSO control identifier string
98 (e.g. ``"FileSave"``, ``"Copy"``, ``"PasteValues"``).
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 ...
107 @abstractmethod
108 def is_enabled(self, mso_id: str) -> bool:
109 """Return whether a ribbon command is currently enabled.
111 Args:
112 mso_id: MSO control identifier string.
114 Returns:
115 bool: ``True`` if the command is enabled; ``False`` otherwise.
117 Raises:
118 ExcelThreadViolationError: If called from the wrong thread.
119 GUIOperationError: If the MSO ID is unknown or the query fails.
120 """
121 ...
123 @abstractmethod
124 def is_pressed(self, mso_id: str) -> bool:
125 """Return whether a ribbon toggle command is currently pressed.
127 Args:
128 mso_id: MSO control identifier string.
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.
135 Raises:
136 ExcelThreadViolationError: If called from the wrong thread.
137 """
138 ...
140 @abstractmethod
141 def is_visible(self, mso_id: str) -> bool:
142 """Return whether a ribbon command is currently visible.
144 Args:
145 mso_id: MSO control identifier string.
147 Returns:
148 bool: ``True`` if the command is visible in the current ribbon
149 state; ``False`` otherwise.
151 Raises:
152 ExcelThreadViolationError: If called from the wrong thread.
153 GUIOperationError: If the MSO ID is unknown or the query fails.
154 """
155 ...
158class AbstractMenuBackend(ABC):
159 """Contract for legacy CommandBar traversal and control execution.
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 """
166 # ///////////////////////////////////////////////////////////////
167 # ABSTRACT METHODS
168 # ///////////////////////////////////////////////////////////////
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.
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.
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 ...
186 @abstractmethod
187 def list_bars(self) -> list[str]:
188 """Return the names of all CommandBars registered with Excel.
190 Returns:
191 list[str]: Sorted list of CommandBar names.
193 Raises:
194 ExcelThreadViolationError: If called from the wrong thread.
195 GUIOperationError: If the COM collection cannot be iterated.
196 """
197 ...
199 @abstractmethod
200 def list_controls(self, bar_name: str) -> list[str]:
201 """Return the captions of all top-level controls in a CommandBar.
203 Args:
204 bar_name: The name of the CommandBar to inspect.
206 Returns:
207 list[str]: Captions of the bar's top-level controls, in order.
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 ...
217class AbstractDialogBackend(ABC):
218 """Contract for file-open, file-save, and message-box dialogs.
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.
224 Default parameter values in implementing methods must match those
225 declared on :class:`~ezxl.gui.DialogProxy`.
226 """
228 # ///////////////////////////////////////////////////////////////
229 # ABSTRACT METHODS
230 # ///////////////////////////////////////////////////////////////
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.
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.
247 Returns:
248 str | None: Absolute path chosen by the user, or ``None``
249 if the dialog was cancelled.
251 Raises:
252 ExcelThreadViolationError: If called from the wrong thread.
253 GUIOperationError: If the underlying call fails.
254 """
255 ...
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.
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``.
272 Returns:
273 str | None: Absolute path chosen by the user, or ``None``
274 if the dialog was cancelled.
276 Raises:
277 ExcelThreadViolationError: If called from the wrong thread.
278 GUIOperationError: If the underlying call fails.
279 """
280 ...
282 @abstractmethod
283 def alert(self, message: str, title: str = "EzXl") -> None:
284 """Display a modal information message box.
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"``.
291 Raises:
292 ExcelThreadViolationError: If called from the wrong thread.
293 GUIOperationError: If the underlying call fails.
294 """
295 ...
298class AbstractKeysBackend(ABC):
299 """Contract for keystroke injection into Excel.
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 """
306 # ///////////////////////////////////////////////////////////////
307 # ABSTRACT METHODS
308 # ///////////////////////////////////////////////////////////////
310 @abstractmethod
311 def send_keys(self, keys: str, wait: bool = True) -> None:
312 """Send a keystroke sequence to the Excel Application window.
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``.
320 Raises:
321 ExcelThreadViolationError: If called from the wrong thread.
322 COMOperationError: If the keystroke injection call fails.
323 """
324 ...
327class AbstractBackstageFileOps(ABC):
328 """Contract for Excel Backstage file operations via COM.
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``.
334 This contract is implemented by
335 :class:`~ezxl.gui.win32com.COMBackstageBackend`. Inject it into
336 :class:`~ezxl.gui.GUIProxy` via the *backstage* parameter::
338 gui = GUIProxy(xl, backstage=COMBackstageBackend(xl))
339 """
341 # ///////////////////////////////////////////////////////////////
342 # ABSTRACT METHODS
343 # ///////////////////////////////////////////////////////////////
345 @abstractmethod
346 def save(self) -> None:
347 """Save the active workbook.
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 ...
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.
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.
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 ...
373 @abstractmethod
374 def open_file(self) -> None:
375 """Show the built-in Excel Open dialog.
377 Raises:
378 ExcelThreadViolationError: If called from the wrong thread.
379 GUIOperationError: If the dialog cannot be opened.
380 """
381 ...
383 @abstractmethod
384 def close_workbook(self) -> None:
385 """Close the active workbook without saving.
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 ...
395class AbstractBackstageNavigator(ABC):
396 """Contract for Excel Backstage visual navigation via UI Automation.
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.
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::
406 gui = GUIProxy(
407 xl,
408 backstage=COMBackstageBackend(xl),
409 backstage_nav=PywinautoBackstageBackend(hwnd=xl.hwnd, locale="fr"),
410 )
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 """
418 # ///////////////////////////////////////////////////////////////
419 # ABSTRACT METHODS
420 # ///////////////////////////////////////////////////////////////
422 @abstractmethod
423 def open_options(self) -> None:
424 """Navigate to the Excel Options panel via the Backstage.
426 Raises:
427 GUIOperationError: If the Options panel cannot be reached.
428 """
429 ...
431 @abstractmethod
432 def open_save_as_panel(self) -> None:
433 """Open the Save As panel in the Backstage without confirming a save.
435 Clicks the ``"Enregistrer sous"`` (or locale equivalent) ListItem
436 and leaves the panel open for manual interaction.
438 Raises:
439 GUIOperationError: If the panel cannot be reached.
440 """
441 ...
443 @abstractmethod
444 def open_file(self) -> None:
445 """Open the Open panel via the Backstage.
447 Raises:
448 GUIOperationError: If the panel cannot be reached.
449 """
450 ...
452 @abstractmethod
453 def close_workbook(self) -> None:
454 """Close the active workbook via a Backstage UIA click.
456 Raises:
457 GUIOperationError: If the action cannot be completed.
458 """
459 ...
462# ///////////////////////////////////////////////////////////////
463# COMPATIBILITY ALIAS
464# ///////////////////////////////////////////////////////////////
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