A reusable, ordered sequence of SASpro commands — each with its own preset — that can be applied in one gesture to the active view, fanned out across every view in a View Bundle, or packed into a chip on the shortcut canvas. Bundles are built by dragging shortcut tiles into the dialog; each tile dropped becomes a step. The dialog remembers your bundles between sessions and persists the chip layout on the canvas across restarts.
The signature behaviour of this tool is synchronous step chaining across asynchronous SASpro commands. Many SASpro tools (Statistical Stretch, GraXpert, Cosmic Clarity, RC-Astro, etc.) return from their drop handler immediately and continue processing in a background worker — so a naive sequence would run step N+1 against step N's unmodified image. The bundle runner solves this by arming a document-change watcher before every step, then blocking the next step until the document reports a change (either via its changed signal or via a bumped edit counter), with a special indefinite wait when Cosmic Clarity's headless flag is set. The result is a reliable, order-preserving pipeline that respects each tool's real completion.
A Function Bundle is a named, ordered list of steps. Each step is a dictionary with at least a command_id (the id of a SASpro command, e.g. stretch_statistical, graxpert_bg, cosmic_clarity_sharpen) and an optional preset dictionary carrying that command's parameters. Steps are shaped exactly like the payloads a shortcut tile drops onto a view, which is why building a bundle is literally “dragging shortcut tiles into a list.”
A single SASpro session can hold any number of bundles, each with its own name. Bundle data lives in QSettings under the key functionbundles/v1 as a JSON array, so bundles survive restart and can be included in .sass shortcut-export files for sharing.
The two panes of the dialog mirror this: the left pane lists bundles, the right pane shows the steps of the currently selected bundle. Everything you can do to a bundle (new, duplicate, delete, rename) is on the left; everything you can do to its steps (reorder, remove, clear, edit preset) is on the right.
Three ways to add steps to the currently selected bundle:
InternalMove mode so you can't drop arbitrary items from outside Qt, only reorder existing ones.A newly created bundle gets a placeholder name like “Function Bundle 3”; rename it with F2 or by double-clicking the entry. Duplicates get a “ (copy)” suffix. The badge on the chip (if any) updates automatically to reflect the current step count.
Each step shows as a row command_id — preset_label. The preset label is derived automatically: if the preset has a name or label field, that string is shown; otherwise a compact summary of its first three keys ({k1, k2, k3…}) is used. Hover a row to see the full line as a tooltip (useful when the row elides).
Available step actions:
Delete / Backspace — remove the selected rows.Ctrl+A — select every step.Return / Enter — open the preset editor on the selected step.Ctrl+drag within the list — reorder (standard Qt internal-move DnD).F2 — rename the current bundle.When you edit a step's preset, SASpro tries the step command's bespoke preset editor first — the same editor you'd see if you right-clicked that command's shortcut tile and chose “Edit Preset…”. These editors know what parameters the command accepts, their ranges, their units, and their validation rules. Not every command has a bespoke editor (particularly third-party plug-ins); for those, the editor falls back to a plain JSON text editor.
Fallback behaviour matters: if a bespoke editor exists and the user cancels it, nothing changes — the fallback JSON editor is NOT opened. Cancelling a rich UI means “I don't want to change this preset,” not “show me the raw JSON instead.” The JSON editor only appears when the command has no bespoke editor at all.
The JSON editor is a plain multiline text box seeded with the current preset in indented JSON. On OK, the text is parsed; if parsing fails, the error is shown and nothing changes. An empty text box commits a None preset (equivalent to clearing it).
Three apply targets at the bottom of the right pane:
During a run, each step logs its index, command id, payload, and completion reason (signal, count, signal_sync, count_sync, timeout, or noop) via the main window's _log hook plus stdout. Cosmic Clarity steps get their own >>> BEGIN CC / <<< END CC markers so they're easy to spot in long logs.
Compress to Chip spawns a small purple widget — the function-bundle chip — on SASpro's shortcut canvas. The chip stays live for the whole session (and re-appears next launch); the dialog itself can be closed without destroying the chip.
Chip behaviours:
function_bundle payload ({"command_id": "function_bundle", "steps": […], "inherit_target": true}). Drop it on a view, a view bundle chip, or any other drop target that handles bundle payloads.The number badge on the chip shows the current step count and recolours (purple when the bundle has steps, grey when it's empty). The chip's title tracks the bundle's name; rename the bundle and the chip updates automatically.
Multiple chips can live on the canvas at once (one per bundle). On launch they're placed in a staggered row near the upper-right so view-bundle chips (which land upper-centre) don't collide with them.
Below the Steps list sits a status label and progress bar. During a run:
If you run the bundle against a View Bundle, the status covers one view at a time — so you see progress through step N/total for view 1, then view 2, and so on.
Function Bundles also integrates with SASpro's command/script system via the CommandSpec id function_bundle. Scripts can call it two ways:
preset["bundle_name"] and the runner looks it up in the same QSettings store the dialog uses.preset["steps"] as a list of step dicts directly. Legacy keys cid and id are accepted as aliases for command_id, so bundles exported from older SASpro versions still run.Optional preset keys:
inherit_target (default True) — if true, every step runs against the same target; if false, each step is dispatched on its own and can retarget.targets — either "all_open" or an explicit list of doc pointers. When present, the bundle fans out exactly as the UI path does; target_sw is set to None and the main-window drop handler does the dispatch.The runner does not step through ctx.run_command itself; it synthesises a single function_bundle payload and hands it to MainWindow._handle_command_drop, which is the same path a UI drop takes. This keeps script execution and UI execution bit-for-bit identical.
Internal mechanisms numbered continuing from the usage sections.
The internal step payload accepted by _handle_command_drop looks like:
Bundles saved from older SASpro versions sometimes stored the command id under cid or id instead of command_id. The script runner's _normalize_steps_for_hcd accepts all three (in order of preference) and rewrites each step into the canonical shape before dispatch. Unknown extra keys pass through unchanged, so new per-command fields don't break old bundles.
The core of the sequential runner. For each step, the runner follows the arm → run → wait cycle:
Arming before running is essential. Many SASpro tools apply synchronously inside their drop handler and emit changed before the dispatcher returns — if the watcher connected after the dispatch, that fire would be missed and the wait would time out even though the step had completed. The watcher connects first, the dispatch happens, and any synchronous signal reaches the listener.
The edit counter is a resilience hedge: not every doc class in SASpro's history has emitted changed consistently. The waiter looks for a counter on each of these attribute names in order: edit_count, _edit_count, revision, _revision, version; failing all of those, it falls back to len(history) / len(_history) / len(undo_stack). Callables are invoked. If none of them exist, the counter check is skipped and the wait depends on the signal alone.
The wait loop pumps Qt events every 25 ms with a sleep in between, so the UI stays responsive — the progress bar updates, status label refreshes, and buttons stay clickable (though their actions queue until the wait returns). On timeout, the wait returns timeout as the completion reason and the runner moves on; the per-step error list stays empty unless the dispatch itself raised.
Some steps intentionally don't modify the image — saves, exports, viewers, inspectors. Waiting for a document change after one of these would always hit timeout. The waiter maintains an explicit no-op set:
For any step whose command_id is in this set, the wait is skipped entirely and the runner moves immediately to the next step. plate_solve is in this set because it writes WCS cards into the header without necessarily emitting changed — keeping it here means plate solving in the middle of a bundle doesn't stall for two minutes before timing out.
Cosmic Clarity is SASpro's AI noise reduction and sharpening tool; it runs as a separate headless process and can take minutes on large images. The bundle runner knows about it specifically.
While a Cosmic Clarity run is in progress, two flags indicate it:
mw._cosmicclarity_headless_running on the main window (set at process launch, cleared on exit).cc/headless_in_progress in QSettings (same lifecycle, visible across process boundaries).If either is true during the per-step wait loop, the 120 second timeout is bypassed — the loop keeps polling as long as CC is running, up to an upper bound of two hours. After CC's flag clears, a secondary _wait_for_cosmicclarity pass runs to make sure the subprocess has fully exited before the next step begins. This two-stage guard means a long CC call followed by Statistical Stretch never races the stretch against a mid-flight CC write.
Chip positions are saved to QSettings under functionbundles/chips_v1 as a JSON array of { "index": …, "x": …, "y": … } records. Writes happen on every chip-drag release and on bundle delete, so the layout is always current.
Restore runs at dialog construction time: for each saved record, the matching bundle is looked up by index (chips bind to bundle positions, so reordering the bundle list without reassigning chips can produce stale bindings — this is why bundle delete explicitly removes the chip for that index). A missing or out-of-range index silently skips. If the shortcut canvas isn't available yet (startup race), the restore is a no-op — it'll run again the next time the dialog is opened.
Chip restore at app startup is driven by restore_function_bundle_chips(parent), which constructs the dialog singleton with auto_spawn_only=True — the dialog itself stays hidden, but its chips land on the canvas as if the dialog had just been opened.
Bespoke preset editors live under setiastro.saspro.shortcuts and are discovered via two lazy imports: _has_preset_editor_for_command(cid) checks whether an editor exists; _open_preset_editor_for_command(parent, cid, preset) opens it. The import is lazy so the Function Bundles module stays independent of the shortcuts module's own circular-import constraints.
If the shortcuts module fails to import or has no editor for the step's command id, the JSON editor fallback runs directly in _edit_preset_dialog — it's a plain QPlainTextEdit seeded with json.dumps(current, indent=2) and accepted only when the content parses as valid JSON.
Function bundles ride along with SASpro's shortcut-export files (.sass). The export side is export_function_bundles_payload(), which reads both the bundle definitions and the chip layout from QSettings and returns them as a { "bundles": […], "chips": […] } dict.
The import side is import_function_bundles_payload(payload, parent, replace_existing=False):
replace_existing=True the current bundles and chip layout are overwritten.replace_existing=False (default) new bundles are appended, and chip indices in the imported payload are offset by the current bundle count so each chip still points to its own bundle after the merge.After the import, any live dialog is refreshed via reload_from_settings_after_import(); if no dialog exists, chip restore runs so the imported chips land immediately on the canvas.
MIME_CMD), the main-window drop dispatcher (_handle_command_drop), the document model's changed signal, and the shortcut-canvas chip layer. There are no external algorithmic references — this tool orchestrates SASpro's existing commands rather than introducing new image-processing primitives. Qt and PyQt6 provide the UI framework, drag-and-drop infrastructure, and QSettings persistence.