← Keyhac crftwr/keyhac on GitHub · craftware

Action API reference

The surface an action uses to drive another application: finding windows, searching element trees, waiting for the screen to change, filling fields. Reached through keymap.ui (or self.ui inside a ThreadedAction) and the methods on the nodes it hands back — the three exception names below are the only ones a config imports. The values the verbs take (Locator, Appears, Front, Gone, Reads, Stable) are reached through ui as well: ui.Locator(...), ui.Appears(...).

Generated from the docstrings. For how to write an action, the authoring skill in keyhac/skills/keyhac-action-authoring/ is the procedural half, and examples/actions/ holds working ones.

A UINode is a snapshot. It records what an element was when it was read; the screen then moves on and the node does not notice. find, find_all and the waits read the live screen each call regardless of the node’s age; walk, dump, children and the text properties show only what was captured. reread() refreshes one deliberately, and StaleElement is raised when a node you are still holding refers to something that no longer exists — which is the signal to re-find it, as distinct from FillFailed, which means the selector was wrong. Address elements by identifier where there is one, then by role plus name or text.

Cross-platform by shape, not by data. Every method here exists and behaves the same on Windows and macOS. What differs is the tree it reads: roles are AXTable / Table, macOS keeps a control’s state in one value where Windows splits it across Value, ToggleState and IsSelected, and neither platform’s attribute names mean anything to the other. An action is written against a screen that was inspected first, so it is not portable — the framework is. UI.enable_content_access() is the one deliberately one-sided call, exposed so an action can make it unconditionally.

Contents: ThreadedAction · UI · UINode · Locator · Appears · Front · Gone · Reads · Stable · WaitTimeout · FillFailed · ActionCancelled · StaleElement

class ThreadedAction

Base class for time-consuming key actions.

Anything slow - network, subprocess, sleeping, heavy computation - must not run inline, because a bound function executes inside the keyboard hook’s deadline. Derive from this and implement starting(), run() and finished() instead.

Three threads, and which one you are on decides what you may touch. starting() and finished() run on the event-loop thread under the engine lock: main-thread-only APIs (UI, windows, AX) are allowed there, and they should stay light-weight because they hold the lock the keyboard hook needs. run() executes on a worker, where input contexts are allowed but windows and AX elements are not.

Actions run concurrently, so a run() that takes minutes no longer holds up every other one. What is still serialized is what has to be: injected keystrokes (one with ctx: batch at a time) and the clipboard save and restore around a paste.

There is no __init__ here to extend. The base keeps no per-instance state - a running action is tracked in a class-level set - so a subclass’s constructor does not need super().__init__(), and one that omits it loses nothing.

The user can stop a running action with Esc, and an action needs to write nothing for that: wait_for raises ActionCancelled, and a long action spends nearly all its time waiting. Use check_cancelled() in a stretch of work that has no wait in it.

class Fetch(ThreadedAction):
     def starting(self):          # main thread, before run
         logger.info("fetching...")
     def run(self):               # worker thread - the slow part
         return do_network_call()
     def finished(self, result):  # main thread, after run
         logger.info(f"got {result}")

property ThreadedAction.keymap

The running Keymap, so an action need not import and look it up.


property ThreadedAction.ui

The action-facing UI API (keymap.ui) - see doc/action-api.md.

An action’s most-used object, so it is one attribute away rather than two lines of lookup at the top of every run().


method ThreadedAction.cancelled

cancelled() → bool

True once the user has asked this action to stop.

Check it in a loop that does not wait - wait_for already raises ActionCancelled on its own, and a loop built out of waits needs nothing else.


method ThreadedAction.check_cancelled

check_cancelled() → None

Raise ActionCancelled if the user has asked this action to stop.

For a stretch of work with no wait in it - a long parse, a big write - where cancellation would otherwise not be noticed until the next wait.


method ThreadedAction.finished

finished(result: Any) → None

Called on the event-loop thread once run() has returned.

Main-thread-only APIs are allowed here too.

Args:


method ThreadedAction.run

run() → Any

Called in the thread pool; may block.

Returns: Anything; it is handed to finished().


method ThreadedAction.starting

starting() → None

Called on the event-loop thread the moment the action triggers.

Main-thread-only APIs (UI, windows, AX) are allowed here; it runs under the engine lock, so keep it light.


class UI

The action-facing view of the desktop. Reached as keymap.ui.


method UI.activate

activate(
    app: 'str' = None,
    title: 'str' = None,
    timeout: 'float' = 10.0,
    retry_interval: 'float' = 2.0
)

Bring a window to the front, and wait until it really is.

ui.activate(app="Google Chrome")

An act with a postcondition, which is what makes it a verb rather than a wrapper: asking a window to activate is not the same as it being in front, and the difference is where a keystroke goes. It was also the last thing an action had to reach around this API to do - keymap.find_window(...).activate() on the loop thread, by hand.

Args:

Returns: The front window’s node. The wait is what makes that the window you asked for, since it returns only once the match is the front one. Where one application has several windows open, window(app=, title=) is the call that names which you meant.

Raises:


method UI.at_point

at_point(x: 'float', y: 'float') → UINode | None

The element under a screen point, in whichever application owns it.

The cheap way into the text layer: the pointer is usually already over the line the user means (design document §6).


method UI.click

click(
    locator: 'Locator' = None,
    within=None,
    node=None,
    given: 'Condition | Callable[[], Any]' = None,
    until: 'Condition | Callable[[], Any]' = None,
    timeout: 'float' = 10.0,
    retry_interval: 'float' = 2.0
)

Find one control and press it, and say what “it worked” means.

SAVE = ui.Locator(role="Button", name="Save")
ui.click(SAVE, within=dialog, until=ui.Appears(SAVE_PANEL))

The platform’s answer is not evidence. An accessibility press is accepted by applications that then do nothing with it - measured, an AXPress on a control drawn by a Chromium application returns success and moves nothing unless that application has been told an assistive client is present. So until is how a caller says what to look for, and the press is repeated every retry_interval until it holds.

Without until it presses once. A blind retry double-acts - double-save, double-submit - so the retry is the caller’s to ask for, and code that does not ask is visibly the weaker code rather than silently the unlucky code.

The act is the whole ladder (keyhac.core.act): a click where the screen can prove the control is at the point about to be clicked, the platform’s press behind it, the focus last. An action never writes the fallback itself, for the same reason set_text owns paste-then-type rather than leaving it to every caller.

Args:

Returns: Whatever until was satisfied with (an Appears hands back the node it found), or the node that was pressed when there is no until.

Raises:


method UI.content_access

content_access(target: 'UINode | None' = None)

Turn content access on for the block, and hand it back afterwards.

with self.ui.content_access():
     ...

enable_content_access() on its own is the one call that changes another application and leaves it changed: nothing turns it off, so the flag outlives the action, the key press and the session. That is not tidiness - it decides behaviour. A press into a Chromium application’s content is live only while the flag is set, so an action that leaves it on makes the next unrelated press work for reasons nobody chose, and one that never set it makes the same press do nothing at all while reporting success.

It does not wait for the application to act on it. Measured on VS Code: the write is accepted at once and the tree is readable at once, but a press only starts working about two seconds later. Waiting here would put that stall in front of every action, to buy what a verified retry gets for nothing - act, check the postcondition, act again (discussion #98). Reading, which is what an action does first, needs no wait at all.

Handing it back is not free on Electron. Measured 2026-09-13: VS Code drops its document within a second of the flag going off, where Chrome keeps the page it built. Whatever reads that window next - an action, or describe_screen - finds a browser shell with nothing in it, and has to ask again and wait.

Nested blocks are counted, so an inner one does not hand back what an outer one still needs. Two different applications at once is not something this counts - an action works in one at a time.

Args:

Yields: Whether the platform did anything (False on Windows, which needs nothing equivalent).


method UI.enable_content_access

enable_content_access(
    target: 'UINode | None' = None,
    enable: 'bool' = True
) → bool

Ask a Chromium or Electron application to expose its content.

macOS only, and safe to call anywhere. Chrome, Edge, VS Code and Slack build no accessibility tree until an assistive client asks: a loaded page measured 59 nodes of browser chrome with no document in it, and 119 with every field addressable once asked. Windows needs nothing equivalent - Chromium enables its renderer tree when a UIA client attaches - so this returns False there, and an action calls it either way rather than branching.

Args:

Returns: True when the platform did something.


method UI.fill

fill(
    text: 'str',
    locator: 'Locator' = None,
    within=None,
    node=None,
    given: 'Condition | Callable[[], Any]' = None,
    until: 'Condition | Callable[[], Any]' = None,
    timeout: 'float' = 10.0,
    retry_interval: 'float' = 2.0
)

Find one field and write text into it.

ui.fill("REC-001", ui.Locator(identifier="record-id"), within=form)

set_text already focuses, verifies the focus landed, writes, and reads the value back, raising FillFailed naming what each mechanism did - so this verb rarely needs an until. What it adds is the locator, the precondition and the one shape every step has.

A FillFailed is not retried, and that is deliberate wherever it names the mechanisms it attempted: the write happened and the read-back disagreed, so doing it again is the double-act hazard. The one that names none is the focus never landing - nothing was written, and focus() has already spent its own wait on it, so a retry here would only repeat a wait. A field that is not ready yet is a given=.

Args:

Returns: Whatever until was satisfied with, or the field.

Raises:


method UI.focused

focused() → UINode | None

The element with keyboard focus right now, as a node.

The cheapest root there is: a key binding already told you which application and which field the user meant (design document §3.2).

Asked each time, not remembered. This used to hand back keymap.focus, which is a snapshot taken while a key was being dispatched - so an action that closed a window and waited for focus to land somewhere else never saw it move, and kept being handed the destroyed element, or the application that no longer had a window. Polling did not help, because polling produces no keystrokes and only a keystroke refreshed it (issue #44).

keymap.focus stays what it was, on purpose. Deciding which key table applies to a keystroke needs the focus that keystroke was aimed at, and re-reading it there would race the key it is dispatching. The two are different questions; this is the one an action is asking.

Returns: The focused element, or None when nothing has focus or the platform could not say. None is an answer - a stale element that fails every attribute read is not.


method UI.menu

menu(
    *path: 'str',
    given: 'Condition | Callable[[], Any]' = None,
    until: 'Condition | Callable[[], Any]' = None,
    timeout: 'float' = 10.0,
    retry_interval: 'float' = 2.0
)

Run a command from the application’s menu bar, by its path.

ui.menu("File", "Export", "As PDF…")

Named for what it is. An earlier name for this was choose, which reads as “pick an option out of a list” - a dropdown, a combo box, a picker - and that is a different verb this does not provide. This one walks the menu bar.

macOS only, and that is a fact about the platform rather than a gap here. There the menu bar is an OS-level part, one per application, readable in full while it is closed - so this finds the leaf in the closed tree and presses that, opening nothing on the way. Windows has no menu bar in this sense (doc/dev/design-notes.md), and this says so rather than pretending.

Args:

Returns: Whatever until was satisfied with, or the menu item.

Raises:


method UI.node

node(element) → UINode | None

Wrap a platform element as a node, reading nothing below it.

The escape hatch for an element obtained some other way - through keymap.focus.element, or a platform call this API does not cover.


method UI.on_main_thread

on_main_thread(func: 'Callable[[], Any]') → Any

Run func on the event-loop thread and return its result.

Every method here already does this, so an action needs it only to make several reads atomic with respect to a UI that is moving underneath - or to call a platform element method this API does not wrap.


method UI.preserve_clipboard

preserve_clipboard()

Put the clipboard back the way it was afterwards.

node.set_text() already does this around its own paste; this is for an action that uses the clipboard for something else.


method UI.scroll

scroll(
    locator: 'Locator' = None,
    within=None,
    node=None,
    by: 'str' = 'down',
    amount: 'float' = 3.0,
    given: 'Condition | Callable[[], Any]' = None,
    until: 'Condition | Callable[[], Any]' = None,
    timeout: 'float' = 10.0,
    retry_interval: 'float' = 0.4
)

Turn the wheel over a view until something shows up in it.

row = ui.scroll(node=table,
                 until=ui.Appears(ui.Locator(text="REC-042"),
                                  within=table))

For the rows that are not there until you scroll. A virtualised list has no element for a row it has not drawn, so no amount of looking finds it and no bound on the walk helps - the only way to read the fortieth row is to bring it into view. That is what this is for, and it is why it is a verb of its own rather than something click does on the way (which it also does, for a control it is about to press).

Scrolling past the target is the hazard, so retry_interval is short and amount modest: the postcondition is looked at between turns, not after a page of them.

Args:

Returns: Whatever until was satisfied with, or the view.

Raises:


method UI.send_key

send_key(
    keys: 'str',
    given: 'Condition | Callable[[], Any]' = None,
    until: 'Condition | Callable[[], Any]' = None,
    timeout: 'float' = 10.0,
    retry_interval: 'float' = 2.0
)

Send a key expression, and say what “it arrived” means.

ui.send_key("Cmd-P", until=Appears(title="Print"))

Nothing can confirm a keystroke arrived - the application may be starting, may have a window of its own in front, may be busy - which is why every action that sends one grows a retry loop of its own. This is that loop, once.

Args:

Returns: Whatever until was satisfied with, or None.

Raises:


method UI.wait

wait(
    condition: 'Condition | Callable[[], Any]',
    timeout: 'float' = 10.0,
    message: 'str | None' = None,
    interval: 'float | None' = None
) → Any

Block until condition() is truthy, and return what it returned.

For a wait that is not “an element appeared” or “an element went away”

condition may also be an Appears / Front / Gone / Reads / Stable rather than a callable - the same question without the lambda, and without the predicate helper an action grows to hold the lambda.

This is the wait for what something else causes - a file appearing, a job finishing, a window someone else opens - where waiting is the whole strategy because nothing you could do would help. What your own act causes is a verb’s until=; what has to be true before your act goes out is its given=.

Raises:


method UI.window

window(
    app: 'str' = None,
    title: 'str' = None,
    class_name: 'str' = None
) → UINode | None

A top-level window, as a node to search inside.

Matches exactly like keymap.find_window and define_keytable: case-insensitive fnmatch, “ ” alternation, “.exe” optional on Windows.

Args:

Returns: The window’s element as a node, or None when nothing matched.


method UI.windows

windows(app: 'str' = None, title: 'str' = None) → list[UINode]

Every matching top-level window, as nodes.

For the cases where “the window” is ambiguous - a browser with several windows open, or an application whose settings live in a second one.


class UINode

One element, projected onto the facts both platforms agree on.

Every member is one of two kinds. find, find_all, reread, the waits and the text layer read the live UI each time they are called, dispatching to the event-loop thread themselves; text, all_text, children, walk and dump are free reads of this snapshot, showing the screen as it was when the node was read.

Attributes:


property UINode.all_text

The text of this element and everything under it.

What a table cell needs: web content puts the visible string in a child AXStaticText, so the cell’s own text is empty and reading a results table off .text silently yields blank columns.

Two kinds of repetition are dropped, both of them WebKit’s doing: a child that merely restates its parent (a label’s AXStaticText carrying the label again, a heading’s child carrying the heading again), and an immediate repeat of the piece just emitted. Repeats that are not adjacent survive on purpose - two cells of a row legitimately holding “37” are data, not noise.


property UINode.text

This element’s own label and content, as one string.

Note is not None, not truthiness: an unchecked checkbox’s value is 0 and an empty field’s is “”, and both are facts an action needs. This is the same trap “read before toggling” exists to avoid.


method UINode.contains_focus

contains_focus() → bool

Whether the keyboard focus is on this element or inside it.

The question to ask when focus() said False and the reason is not obvious. Two cases answer True here and want opposite responses:

Which of the two it is, the tree says; UI.focused() names the element that actually has the focus.


method UINode.dump

dump(max_value: 'int' = 60) → str

This subtree as indented text - to read, and to hand to an AI agent.

Prints the snapshot as held: a node from ui.window() or ui.node() has read nothing below itself yet, so reread() first.


method UINode.find

find(
    max_depth: 'int' = 14,
    max_nodes: 'int' = 1000,
    **criteria
) → 'UINode | None'

The first element below this one matching criteria, or None.

Reads the live UI at call time - this node’s captured children play no part, so an old window node finds what is on screen now.

What comes back is a node of that walk, not a bare handle, so its children, walk() and all_text are already filled in - reading a cell you just found needs no second call. The subtree it carries is bounded by the same max_depth, counted from this node: something matched near the bottom of a default walk has little or nothing below it, and web content keeps its string one level down. A table that reads as blank columns is that, and table.reread() is the fix rather than a deeper search for cells. None rather than an exception, because only the caller knows whether a missing element is a failed precondition or an expected absence - wait_for is the one that insists.

Args:


method UINode.find_all

find_all(
    max_depth: 'int' = 14,
    max_nodes: 'int' = 1000,
    **criteria
) → list['UINode']

Every element below this one matching criteria, in tree order.

The same live read as find, and the same snapshot rule for what it hands back - each match carries its own subtree, bounded by this call’s max_depth.

A table’s rows come back once each. The tree is a DAG - a cell is a child of its row and of its column both - but the walk dedupes on element identity, so this is already handled; it only bites code that walks children by hand.

Args:


method UINode.focus

focus(timeout: 'float' = None) → bool

Give this element keyboard focus; True when it actually landed.

Landing is not instant, so the ask is repeated for timeout seconds (keyhac.core.fill.FOCUS_TIMEOUT by default) before the answer is False. Pass 0 to ask exactly once.

Landed means on this element. A container that merely holds the focused control - a group, a document, a window - answers False, since a keystroke sent on its word would go to whichever control inside it actually has the focus. So does a control that hands its focus to a part of itself: a Windows combo box focuses its edit child, and that child is the thing to name. contains_focus() tells the two apart.


method UINode.has_focus

has_focus() → bool

Whether the keyboard focus is on this element right now.

A live read, not a fact about the snapshot: it asks the screen each time. This is what focus() checks, and the two are worth separating when an action wants to know where the focus is without moving it.


method UINode.line_at_caret

line_at_caret() → str | None

The line the caret is on - no selection, no pointer.


method UINode.press

press() → None

Press this element, by whichever action name the platform uses.


method UINode.read_text

read_text() → str | None

The whole text content, descending into child text nodes.

Distinct from the text / all_text properties, which are free reads of the snapshot: this asks the application, and is what a terminal buffer or a document body needs.


method UINode.reread

reread(
    max_depth: 'int' = 14,
    max_nodes: 'int' = 1000,
    roles: 'str | None' = None,
    prune=None
) → 'UINode'

Read this subtree again, returning a fresh node.

A UINode is a snapshot: the screen moves on, and nothing here notices.


method UINode.selection

selection() → str | None

The selected text (“” is a real answer, meaning a bare caret).


method UINode.set_checked

set_checked(checked: 'bool') → bool

Set a checkbox, reading it first. True when it pressed.


method UINode.set_text

set_text(text: 'str', **options) → str

Write text into this field and prove it arrived.

Returns the mechanism that worked; raises FillFailed when none did. Takes the same options as keyhac.core.fill.set_text.


method UINode.wait_for

wait_for(
    timeout: 'float' = 10.0,
    message: 'str | None' = None,
    max_depth: 'int' = 14,
    max_nodes: 'int' = 1000,
    **criteria
) → 'UINode'

Wait until an element matching criteria exists below this one.

Args:


method UINode.wait_until_gone

wait_until_gone(
    timeout: 'float' = 10.0,
    message: 'str | None' = None,
    max_depth: 'int' = 14,
    max_nodes: 'int' = 1000,
    **criteria
) → None

Wait until nothing below this one matches criteria.

A bound makes “gone” mean “not found within the bounds”: an element deeper than max_depth counts as gone.

Args:


method UINode.wait_until_stable

wait_until_stable(
    quiet: 'float' = 0.3,
    timeout: 'float' = 10.0,
    **bounds
) → None

Wait until this subtree stops changing.


method UINode.walk

walk() → Iterator['UINode']

This node and every descendant in the snapshot, depth first.

A walk over what was captured, not what is on screen: it yields the nodes already held, asking the OS nothing. On a node read with max_depth=0 - which is what ui.window() and ui.node() return - that is this node alone. find_all() is the one that searches the live tree; reread().walk() traverses a fresh capture.


class Locator

Which element, as a value.

SAVE_PANEL = Locator(identifier="save-panel", max_depth=2)
ui.click(SAVE, within=dialog, until=ui.Appears(SAVE_PANEL, within=dialog))

Lifted out of the call so that a repair is a patch rather than a rewrite. The same selector is written four times in one real action - the act, its postcondition, the dismissal and a read - and as keyword arguments those are four places anything fixing it has to find and understand. As a value they are one, and its repr is a Python literal: storable, diffable, and replaceable without parsing code (discussion #98).

The keywords are find_elements’ own, so nothing new has to be learned to write one, and the walk bounds ride along because a locator that needs a deeper walk needs it everywhere it is used.

within is deliberately not here. Scope is a live node, and a value holding a handle is not a value any more - it cannot be written down, kept or compared. It stays an argument of the call that uses the locator.

predicate can be here, and is the one field nothing can patch. That is worth showing rather than hiding: a caller reading a locator can see exactly which part of it is opaque.

Attributes:


method Locator.criteria

criteria() → dict

The find_elements keywords this locator stands for.

find and find_all take criteria rather than a locator, so this is what lets one written-once selector serve a verb and a plain search both:

ROW = ui.Locator(role="Row")
rows = table.find_all(**ROW.criteria())

Returns: The fields that were set, as keyword arguments. The unset ones are left out rather than passed as None, so the search’s own defaults still apply.


class Appears

Satisfied when something matching this locator exists.

With a locator it is an element question, without one a window question, which is the whole rule: the thing a dialog’s button opens is often a window rather than an element in one, and app / title name it. Given a locator and app / title, it looks for the element in those windows - because which window is often what the action cannot answer first. within scopes it to one node instead.

Satisfied with a value: the node it found, so the verb that waited for it can hand it back.

Attributes:


class Front

Satisfied when the front window is this one.

The precondition that is not about the target, and the one a verb cannot fold without a name for it: an action sends Cmd-P to the browser, and after a save the application’s own download popup owns the front for a few seconds and swallows it. Waiting for the right window to be in front is the difference between a retry that converges and one that types into whatever is there.

Attributes:


class Gone

Satisfied when target is no longer there.

target is a Locator (with within, if it is scoped), an Appears, or a node - the dialog you just dismissed. Prefer the described forms: a held node can only be asked whether its own reference has died, which a platform answers well for a closed window and badly for a row that was merely removed from a list.

Attributes:


class Reads

Satisfied when target reads as the values given.

The postcondition to reach for. The rule the authoring skill states - wait for the state you expect, not for the old state to change - is this value: “it differs from what I captured” and “the result arrived” coincide only when the new state happens to differ, and a transform can be the identity. A translation whose output equals its input leaves a Changed waiting forever with the screen already correct.

value is compared as text against the same patterns find takes, so value="True" matches a macOS AXValue of True and a Windows toggle state of "True" without the caller knowing which it got.

Attributes:


class Stable

Satisfied when a subtree has stopped changing.

For the re-render nobody can name: a dependent field repopulating, a table repainting after a sort. Say the state you expect wherever you can - Appears(text="page 2"), Reads(row, value=…) - because this is the escape for what cannot be named, and an escape is easy to reach for.

It is a precondition, never a postcondition, and the API refuses it as one. Quiet is the absence of change, so it is satisfied by the calm before an act’s effect starts as readily as by the calm after: as an until= it has a race built into its meaning. As a given=, or in a wait() of its own before a read, it says something true - “do not act into a screen that is still moving”, “let it finish, then read”.

Its question is about a stretch of time rather than an instant, so unlike the other values it cannot be asked by polling a predicate; it brings its own wait.

Attributes:


class WaitTimeout

A wait gave up.

Deliberately its own type, and deliberately an error rather than a False return: an action whose precondition never arrived must stop, not carry on against a screen that is not there (design document §3.7).


class FillFailed

A write did not take.

Carries what was attempted, because “the field is still empty” and “the field has the wrong text” want different responses from the caller.

An empty attempted means nothing was written at all - the focus never landed, so the write was refused rather than attempted. That one is a precondition failure wearing this exception’s name: the world was not ready, as against the act not having taken, and only the second carries the double-act hazard that makes retrying dangerous.


class ActionCancelled

Raised inside a running action when the user cancels it with Esc.

Derived from BaseException rather than Exception, and that is the point. An action of the kind this framework exists for catches Exception around each item, because partial failure is the thing it is built to survive:

for system in self.systems:
     try:
         self._read_system(system, rows)
     except Exception as error:          # <- would swallow a cancellation
         self.failed.append((system["name"], str(error)))

Were this an ordinary Exception, pressing Esc there would be recorded as “SystemA failed” and the run would carry on to SystemB - the one thing cancelling must not do. As a BaseException it passes through every such handler while still unwinding the action’s finally blocks, so progress already written stays written.

Cancellation is KeyboardInterrupt’s cousin, not an error. An action never needs to know this class exists: wait_for raises it, and long actions spend most of their time waiting.


class StaleElement

The element this node was read from no longer exists.

A UINode is a snapshot. It records what an element was when the tree was walked; the screen moves on and the node does not notice. That is the contract on purpose - a node that quietly re-read itself would hide exactly the change an action’s preconditions exist to catch.

So the node has to say when it has gone stale, and say it in a way an action can act on. The distinction this exists for is the one §3.7 turns on:

Before this existed both arrived as “element supports no press action”, because a dead element reports no actions - true, and the least useful true thing to say.


Generated from the docstrings by make api-reference. Edit the docstrings, not this file.