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
UINodeis 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_alland the waits read the live screen each call regardless of the node’s age;walk,dump,childrenand the text properties show only what was captured.reread()refreshes one deliberately, andStaleElementis 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 fromFillFailed, which means the selector was wrong. Address elements byidentifierwhere 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
ThreadedActionBase 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}")
The running Keymap, so an action need not import and look it up.
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().
ThreadedAction.cancelledcancelled() → 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.
ThreadedAction.check_cancelledcheck_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.
ThreadedAction.finishedfinished(result: Any) → None
Called on the event-loop thread once run() has returned.
Main-thread-only APIs are allowed here too.
Args:
result: Whatever run() returned.ThreadedAction.runrun() → Any
Called in the thread pool; may block.
Returns: Anything; it is handed to finished().
ThreadedAction.startingstarting() → 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.
UIThe action-facing view of the desktop. Reached as keymap.ui.
UI.activateactivate(
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:
app: Application pattern, as window() takes it.title: Window title pattern.timeout: Seconds before giving up.retry_interval: Seconds to watch before asking again - an application starting up can take more than one ask.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:
WaitTimeout: It never came to the front.UI.at_pointat_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).
UI.clickclick(
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:
locator: Which control, as a value (ui.Locator).within: Where to look; the focused window by default. Not part of the locator, because scope is a live node and a value holding a handle stops being one.node: A node already in hand, instead of a locator - the third row of a list an earlier step enumerated is a thing no locator says well.given: What must hold before each attempt - state of the world somebody else has to have arranged, which this waits for and never causes. It is re-checked before every attempt, andthat is the whole reason it is a parameter: with no until it is only sugar for wait() then the call, but with one, a hoisted wait() guards the first attempt and nothing after it. It also fails distinctly - a precondition that never held and an act that did not take are different diagnoses.until: What makes it true - what this act produces, which is the definition of it having landed, and a separate clause only because the platform lies about success. Waiting here for something the act does not cause fires it again and again into a door that is not open. None presses once and returns.timeout: Seconds before giving up, in total.retry_interval: Seconds to watch the postcondition before pressing again - the only rate here, because how often to look is wait_for’s backing-off default and cannot be got expensivelywrong, while pressing again can: too short, and a dialog that takes three seconds to open gets pressed three times.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:
WaitTimeout: The target never appeared, or the postcondition never held.StaleElement: The target was there and had gone by the time it was pressed.UI.content_accesscontent_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:
target: A node in the application, or None for the focused one.Yields: Whether the platform did anything (False on Windows, which needs nothing equivalent).
UI.enable_content_accessenable_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:
target: A node in the application, or None for the focused one. Any node will do; the request goes to its application.enable: False to give it back. What that does depends on the application - measured 2026-09-13, an Electron one (VS Code) drops its whole document within a second, while Chrome keeps the page it has already built. So on Electron this is a switch the next reader feels, and it will find a window with no document in it.Returns: True when the platform did something.
UI.fillfill(
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:
text: What to write.locator: Which field, as a value.within: Where to look; the focused window by default.node: A field already in hand, instead of a locator.given: What must hold before the write.until: What makes it true, when the read-back is not the whole story - a form that only enables Save once the field is valid.timeout: Seconds before giving up, in total.retry_interval: Seconds to watch until before writing again.Returns:
Whatever until was satisfied with, or the field.
Raises:
FillFailed: Every mechanism was tried and the value did not stick.WaitTimeout: The field never appeared, or until never held.UI.focusedfocused() → 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.
UI.menumenu(
*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:
*path: Menu names from the bar down to the command.given: What must hold before the command is pressed.until: What makes it true - a dialog appearing, usually.timeout: Seconds before giving up, in total.retry_interval: Seconds to watch until before pressing again.Returns:
Whatever until was satisfied with, or the menu item.
Raises:
WaitTimeout: The path was not there.ValueError: This platform has no menu bar.UI.nodenode(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.
UI.on_main_threadon_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.
UI.preserve_clipboardpreserve_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.
UI.scrollscroll(
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:
locator: Which view to scroll.within: Where to look for it; the focused window by default.node: The view already in hand, instead of a locator.by: "down" or "up".amount: Wheel notches per turn.given: What must hold before each turn.until: What makes it true. None turns the wheel once.timeout: Seconds before giving up, in total.retry_interval: Seconds to watch before turning again.Returns:
Whatever until was satisfied with, or the view.
Raises:
WaitTimeout: It never showed up.UI.send_keysend_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:
keys: A key expression, as InputContext.send_key takes it.given: What must hold before each attempt - Front is the one this verb is usually given, because a keystroke goes to whatever is in front rather than to whatever you meant, and what was in front when the first attempt went out need not be in front for the second.until: What makes it true; None sends it once.timeout: Seconds before giving up, in total.retry_interval: Seconds to watch the postcondition before sending again; how often to look is not a parameter, for the reason click gives.Returns:
Whatever until was satisfied with, or None.
Raises:
WaitTimeout: The postcondition never held.UI.waitwait(
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”
node.wait_for() and node.wait_until_gone(). Never sleep: a fixed delay passes on the machine it was written on, and on a faster one it fails silently, acting on a screen that has not arrived.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:
WaitTimeout: The condition never became true.UI.windowwindow(
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:
app: Application name pattern.title: Window title pattern.class_name: Win32 class name pattern (Windows only).Returns: The window’s element as a node, or None when nothing matched.
UI.windowswindows(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.
UINodeOne 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:
role: Control role - “AXTextField” (macOS) or “Edit” (Windows). The OS’s own name; match it with role= patterns, which accept the macOS names with or without their “AX” prefix.name: The element’s label, not its content (“Query” for a field labelled Query).value: The element’s content (what is typed into the field, “0”/”1” for a checkbox).name_source: Which attribute name came from - “label”, “description”, “help”, or None when the element has no name. An icon-only button typically has no label and answers one of the other two; nothing at all means it can be addressed only by role and position.identifier: A stable identifier where the platform has one - the DOM id in web content, AXIdentifier in native macOS UI, AutomationId on Windows. The best thing to address an element by when present, since it survives relabelling and localisation.rect: (x, y, w, h) in screen coordinates, or None.depth: Distance below the root the walk started from.element: The platform UIElement, for anything outside this projection.children: Child nodes, in the platform’s own order.truncated: True when this node’s children were cut off by max_depth or max_nodes - so a caller can tell “leaf” from “gave up here”.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.
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.
UINode.contains_focuscontains_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.
UINode.dumpdump(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.
UINode.findfind(
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:
max_depth: Depth bound for the underlying walk. Web content can nest controls deeper than the default; raise this before concluding an element is not there.max_nodes: Node budget for the underlying walk.**criteria: role, name, value, identifier, text and predicate; patterns are case-insensitive fnmatch with “ |
” alternation. |
UINode.find_allfind_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:
max_depth: Depth bound for the underlying walk.max_nodes: Node budget for the underlying walk.**criteria: As find.UINode.focusfocus(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.
UINode.has_focushas_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.
UINode.line_at_caretline_at_caret() → str | None
The line the caret is on - no selection, no pointer.
UINode.presspress() → None
Press this element, by whichever action name the platform uses.
UINode.read_textread_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.
UINode.rereadreread(
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.
UINode.selectionselection() → str | None
The selected text (“” is a real answer, meaning a bare caret).
UINode.set_checkedset_checked(checked: 'bool') → bool
Set a checkbox, reading it first. True when it pressed.
UINode.set_textset_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.
UINode.wait_forwait_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:
timeout: Seconds before giving up.message: What was being waited for, for the timeout error.max_depth: Depth bound for the walk. Every poll walks the tree again, so this is a cost bound as much as a reach bound.max_nodes: Node budget for the walk.**criteria: As find.UINode.wait_until_gonewait_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:
timeout: Seconds before giving up.message: What was being waited for, for the timeout error.max_depth: Depth bound for the walk.max_nodes: Node budget for the walk.**criteria: As find.UINode.wait_until_stablewait_until_stable(
quiet: 'float' = 0.3,
timeout: 'float' = 10.0,
**bounds
) → None
Wait until this subtree stops changing.
UINode.walkwalk() → 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.
LocatorWhich 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:
role: Role pattern, with macOS’s AX prefix optional.name: Label pattern.value: Content pattern.identifier: DOM id / AutomationId - the first thing to reach for, because it survives relabelling and localisation.text: Label and content together, for when you do not know which of the two the application put the caption in.predicate: A callable taking a node - the escape, and the one field nothing can patch.max_depth: How deep to walk (#81).max_nodes: How many nodes to read before giving up.Locator.criteriacriteria() → 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.
AppearsSatisfied 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:
locator: Which element. Without one this is a window question.within: The node to search under.app: Application pattern, to search that application’s windows.title: Window title pattern.FrontSatisfied 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:
app: Application pattern.title: Window title pattern.GoneSatisfied 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:
target: A Locator, an Appears, or a node.within: The scope, when target is a bare locator.ReadsSatisfied 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:
target: The node to read.role: Role it should report, if that is what changed.name: Label it should report.value: Content it should report, compared as text.StableSatisfied 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:
within: The subtree that must settle; the front window by default.quiet: Seconds of no change required.max_depth: Depth bound for each read - bound it, because this costs one tree read per look.max_nodes: Node bound for each read.WaitTimeoutA 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).
FillFailedA 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.
ActionCancelledRaised 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.
StaleElementThe 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:
StaleElement - the screen moved. Re-find the element and carry on, or stop and hand back to a human. The action is not wrong.FillFailed / an empty search - the selector is wrong. The action was written against a screen that is not this one, and running it again will fail the same way.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.