Python for Windows automation with 16 typed exceptions, not try/except Exception
Most playbooks for this teach you pyautogui (one exception class) or pywinauto (three). Then they show you a click, a sleep, and a try/except Exception loop. We are going to do something else. Terminator's Python binding raises a different exception for every distinct way a Win32 control can refuse you. There are sixteen of them, and you should know which one means what.
The proof, before the pitch
Open the file packages/terminator-python/src/exceptions.rs in the Terminator source. Lines 4 through 71 declare the exception classes through PyO3's create_exception! macro. Lines 76 through 98 define automation_error_to_pyerr, the function that maps every variant of the Rust AutomationError enum to its corresponding Python class. The match is exhaustive at compile time. There is no catch-all bucket. If a new failure mode appears in the Rust core, the Rust compiler refuses to build the Python bindings until a new exception is added.
Every name above is a real class in the imported terminator module. You can from terminator import ElementObscuredError and write that name in an except clause.
Why 0 instead of one
The accessibility API on Windows already knows the difference between "the control is not visible because it is off-screen" and "the control is not visible because another window is painted over it." Those two states have different fixes. The first one wants a scroll. The second one wants a focus change. If your library throws away that distinction at the boundary and presents you with one error type, your script has to guess which fix to apply. So you write try: click(); except: time.sleep(1); click(). Then you have a flake.
Terminator surfaces both as ElementNotVisibleError and ElementObscuredError. Different classes, different except branches, different fixes, no guessing.
How a click flows through the type system
When you call element.click() from Python, the call descends into the Rust core, hits the Windows UIA backend, and one of several things can happen. Each path ends at a different Python exception. The diagram below is the dispatch from automation_error_to_pyerr.
One Python call, six possible typed failures
“Catching all of them as Exception throws away the diagnostic that the accessibility API already gave you for free.”
terminator-py vs pywinauto exception surface
The hello world, with real except branches
This is the script. Save it as click_save.py and run it. Notice that none of the except blocks contain a naked Exception. The types do the routing for you.
Reproduce four exceptions in 30 seconds
You do not have to take the dispatch table on faith. Open Notepad, paste the script, and watch four different exception classes print to your console.
A real handler, not a sleep loop
Here is what a self-healing click helper looks like when each failure mode has its own type. Read the except branches top to bottom. Each one knows what to do because the exception name tells it.
Rules for using these exceptions
- Catch ElementNotVisibleError before scrolling. Scrolling without a reason can dismiss popovers.
- Catch ElementObscuredError before reactivating windows. Activation steals focus from the user.
- Catch ElementNotStableError and switch to perform_action('invoke') instead of looping with sleeps.
- Catch ElementDetachedError separately from ElementNotFoundError. Stale handle is not a missing element.
- Catch ElementNotEnabledError last, and DO NOT retry. The upstream form state is the bug.
- Catch TimeoutError at the locator level, not the action level. Timeouts mean your selector is wrong.
- Catch InvalidSelectorError once at startup. It fires synchronously during locator construction.
- Use except Exception only as the outermost guard. Never as the inner handler around a single action.
How it stacks up
| Feature | pywinauto | Terminator (terminator-py) |
|---|---|---|
| Distinct exception classes | 3 (ElementNotFoundError, ElementAmbiguousError, TimeoutError) | 16, one per failure mode the accessibility API can report |
| Off-screen vs covered vs animating vs stale handle | All collapsed into ElementNotFoundError | ElementNotVisible, ElementObscured, ElementNotStable, ElementDetached |
| Disabled control | Click silently no-ops or raises generic Exception | ElementNotEnabledError tells you the IsEnabled property was false |
| Bad selector grammar | Mostly bare strings, no separate parser error | InvalidSelectorError is raised before any UI lookup runs |
| Underlying engine | Pure Python on top of UIA COM | Rust core via PyO3, same engine as the Node and MCP bindings |
| Selector grammar | child_window kwargs (control_type=, title=, ...) | Playwright-style strings (role:Button && name:Save) with combinators |
Setup in five steps
The whole thing fits on one screen. Note step 5: never use try/except Exception on a single action. The point is not to catch errors, the point is to know which error you caught.
From zero to typed-exception script
Install the wheel
pip install terminator-py. Ships PyO3 wheels for cp310 to cp313, win_amd64. The Rust core is statically linked, no extra DLLs to install.
Import the typed exceptions
from terminator import Desktop, ElementNotVisibleError, ElementObscuredError, ElementNotStableError, ElementDetachedError, ElementNotEnabledError, ElementNotFoundError, TimeoutError, InvalidSelectorError. All 16 classes live at the top level of the terminator module.
Construct a Desktop
desktop = terminator.Desktop(log_level='error'). One handle owns the COM apartment that talks to UI Automation. Pass use_background_apps=True if you want to operate on a window that does not have foreground focus.
Locate by selector, not by coordinates
desktop.locator('process:notepad >> role:Button && name:Save'). The grammar matches Playwright's web selectors but resolves against the UIA tree. >>, &&, ||, ! all work. Wildcards do not.
Wrap actions with the right exception
Each except clause knows what to do because the type names the problem. ElementNotVisible scrolls. ElementObscured raises focus. ElementNotStable switches to invoke(). ElementDetached re-resolves. Stop using try/except Exception.
Other tools people reach for
These are the libraries that come up when you ask around. Each works. None of them gives you sixteen typed exception classes, and most of them give you one or two. That is the whole comparison.
The point in one paragraph
When the operating system already knows that a control was off-screen, covered, animating, disabled, detached, or just missing, the Python library you are using should not flatten that into Exception. The underlying accessibility API gave you six different diagnostics for free. Use them. The dispatch from Rust AutomationError variants to Python exception classes is exhaustive at compile time, in a 23-line function in packages/terminator-python/src/exceptions.rs. Read it once. Then write except clauses that mean something.
Walk through your worst flaky script with us
Bring the script that breaks once a week. We will rewrite it against terminator-py with typed exceptions and tell you which class your real bug was hiding behind.
Frequently asked questions
What does pip install terminator-py actually give me?
A PyO3 wheel built from the Rust core in crates/terminator. The Python module exposes Desktop, UIElement, Locator, plus 16 custom exception classes registered in packages/terminator-python/src/exceptions.rs. The wheel ships Windows binaries today (cp310 through cp313, win_amd64). The same Python API exists for macOS through the Accessibility API at the Rust level; the wheels for that platform follow the Windows release cadence.
Why split element failures into 6 separate exception types?
Because they are six different bugs and each has a different fix. ElementNotVisibleError means the bounds are off-screen, you scroll into view. ElementObscuredError means another window is on top, you raise focus. ElementNotStableError means the bounds are still animating, you wait or retry. ElementNotEnabledError means a required field upstream is missing, you check form state. ElementDetachedError means your handle is stale, you re-find. ElementNotFoundError means your selector is wrong, you fix the selector. Catching all of them as Exception throws away the diagnostic that the accessibility API already gave you for free.
How does this compare to pywinauto and pyautogui error handling?
pywinauto exports ElementNotFoundError, ElementAmbiguousError, and TimeoutError, plus a few connection-related errors. Three useful element states. pyautogui exports FailSafeException, which fires when you yank the mouse to a screen corner to abort. One. Terminator exports 16. The mapping from each Rust AutomationError variant to its Python class is at exceptions.rs lines 76 to 98 in the automation_error_to_pyerr function, so the dispatch is exhaustive at the type system level, not best-effort.
Is this actually using Windows UI Automation under the hood?
Yes. The Windows backend lives in crates/terminator/src/platforms/windows. It calls the UI Automation COM API through the windows crate. When you write desktop.locator('process:notepad >> role:Edit').first(timeout_ms=2000) the runtime walks the UIA tree filtered by process id, then by control type, returns the first match. There is no pixel matching and no image template by default. OCR and screenshots are available as supplementary methods on Desktop and UIElement, but they are not the default path.
Why do clicks on radio buttons fail with a normal .click()?
On Windows the radio button control implements the SelectionItem pattern, not the Invoke pattern, so a synthetic mouse click can land on the right pixel without flipping the selection. Terminator surfaces this as ElementNotEnabledError or sometimes a no-op click that returns success. The fix is element.set_toggled(True) for checkboxes and the equivalent SelectionItem.Select call for radio buttons, which the Rust core routes through the correct UIA pattern. The same rule applies in pywinauto and any other UIA-based library, but Terminator names the failure where pyautogui silently does nothing.
What is ElementNotStableError actually checking?
Before clicks, Terminator samples the element's bounds twice with a small delay between samples. If the rectangle moved between samples, the element is mid-animation, and clicking will land on whatever happens to be under the cursor when the click fires. Rather than pretend the click worked, the runtime raises ElementNotStableError so your script can wait or use element.invoke() (which does not require a stable bounding box because it routes through the UIA Invoke pattern, not a synthetic mouse event).
Do I have to write everything async?
Most read-only operations are sync (role(), name(), bounds(), is_visible(), attributes()). The element-finding methods are async because they wait on the UI tree (locator.first(), locator.all(), locator.wait()). Actions like click(), type_text(), press_key() are sync because they execute against an already-resolved element. The split exists so your hot path is not paying for an event loop tick on operations that do not need one. If you do not want async at all, set a timeout on the locator and call the wait helpers from inside asyncio.run(...) once at the top of your script.
What about cross-platform? Will the same script run on macOS?
The Python API is the same. Desktop, UIElement, Locator, and all 16 exception classes are exposed identically from the PyO3 binding regardless of platform. The selector grammar is the same. What changes is what the underlying accessibility tree looks like; macOS AX roles are different from Windows UIA control types. You will write platform-aware selectors (for example, role:button works on both, but nativeid: values from Inspect.exe will not exist on macOS). The exception types are stable. ElementObscuredError on macOS still means another window is on top.
Adjacent guides on the same SDK
Keep reading
Windows automation in Python with a real visual debugger
element.highlight() draws a real Win32 GDI overlay around any UI element your script touches.
Python automation for Windows from the SDK side
How the PyO3 binding wires Rust selectors and async locators into idiomatic Python.
Accessibility API desktop automation explained
Why selectors against the UIA tree beat pixel matching for every long-running script.