Repository navigation
v0.3.15
Desktop state capture is roughly 10× faster, and far more than that on complex pages — with the captured node set unchanged.
Highlights
| workload | 0.3.14 | 0.3.15 |
|---|---|---|
| browser window + system UI | 695.7 ms | ~69 ms |
| heavy DOM page (7,174 elements walked) | 18.0 s | 0.83 s |
The win scales with tree size, so the pages that hurt most improve most. An 18-second capture becoming 0.83 s is the difference between unusable and interactive.
Nothing was traded away for it. Node sets were compared entry by entry — role, name and bounding box — on both workloads: 0 lost, 0 gained.
What was actually wrong
Three AXValue parsers probed a PyObjC bridge object with hasattr/getattr before falling back to the correct unboxing call:
if hasattr(pos_val, "x") and hasattr(pos_val, "y"): # ~178 µs on an AXValueRef
return (pos_val.x, pos_val.y)
try:
success, point = AXValueGetValue(...) # ~0.8 µs — the right answerOn an AXValueRef a missing attribute raises and unwinds through the bridge, so the probe costs ~178 µs while AXValueGetValue costs ~0.82 µs — about 200× cheaper. The expensive test was guarding the cheap answer, twice per element.
Profiling attributed 0.274 s of a 0.722 s capture to builtins.hasattr alone. Fixing the ordering in _parse_ax_position, _parse_ax_size and ParseCFRange accounted for two thirds of the total improvement.
Changed
- AXValue parsers unbox before probing. As above — the single largest factor.
- Per-value type classification is memoised.
GetMultipleAttributeValuesranisinstanceagainst a six-type tuple for every returned value: ~3.6 µs on a bridge type, roughly 8,000 times per capture. The answer depends only on the concrete type, and PyObjC returns very few here. Semantics are identical, includingboolbeing anintsubclass. - Traversal no longer descends into every collapsed container. The zero-area rule added in 0.3.14 stopped a degenerate box from pruning its subtree — which fixed real node loss, but walked every collapsed wrapper on a page and was expensive on DOM-heavy sites. It now descends only when the parent had a usable box, which is the overlay pattern that needed rescuing, and not when a collapsed wrapper sits inside another. Elements walked dropped from 738 to 409 with no node lost, recovering the capture-time regression 0.3.14 introduced while keeping the elements it was added to rescue.
- Children arrays are materialised once rather than re-crossed over the PyObjC bridge by each
len(),reversed()and index.
Notes
The screenshot path (use_vision=True) is unchanged in this release.
Two optimisations were measured and rejected rather than shipped: splitting the attribute fetch into two tiers (the per-call base cost dominates, so it loses on arithmetic), and prefetching attribute batches on a worker pool (measurably slower — a single application's accessibility server appears to serialise requests, so overlapping within one application buys nothing).
Full Changelog: v0.3.14...v0.3.15