Windows automation with Python, with PowerShell built into the library

Open any guide on doing Windows automation from Python and it tells you to glue together three things: pywinauto or pyautogui for the UI, subprocess for shelling out, and some retry logic you write yourself. Terminator's Python binding collapses one of those into the same object that clicks your buttons. desktop.run(command, shell="powershell", working_directory="C:\\build") is a GitHub Actions-style shell runner that lives on the same terminator.Desktop you use to open Notepad. One async function, no subprocess import.

M
Matthew Diakonov
10 min read
4.9from dozens of design partners
PowerShell is the default Windows shell (lib.rs line 577)
working_directory auto-rewrites your command as cd 'cwd'; ...
Typed CommandOutput(exit_status, stdout, stderr) on the awaitable

What every other playbook on this topic leaves out

The canonical advice for doing desktop automation from Python on Windows reads like it was written in 2015. Install pywinauto. Use pyautogui when pywinauto cannot see a control. Import subprocess when you need to run a script. Escape your quoting carefully. Add shell=True. If the command is long, write a .ps1 file next to your .py and invoke it with arguments. Repeat.

None of that is wrong; it just leaves the shell outside your automation graph. Your Python script ends up with two personalities: one half is a UIA client that thinks in roles and locators, the other half is a quoting-escape generator that produces PowerShell. The two halves do not share a clock, a working directory, or an error type.

Terminator folds the shell into the automation library. The method signature is desktop.run(command, shell=None, working_directory=None), declared at packages/terminator-python/src/desktop.rs:173. The core implementation it forwards to sits at crates/terminator/src/lib.rs:569. Both files are in the public repository and you can read the platform-switch logic in about sixty lines.

The one script this page is really about

A real async def you can paste into a Python 3.11 file on Windows. It awaits a multi-line PowerShell block, opens Notepad, finds the editor, and pastes the shell's stdout into the document. Nothing else is imported.

hero.py
1 rewrite

cd 'C:\\logs'; Get-ChildItem -Filter '*.log' | Measure-Object -Property Length -Sum

The exact string Terminator builds and hands to PowerShell when you pass working_directory='C:\\\\logs' with shell='powershell'. Logic at crates/terminator/src/lib.rs line 581.

The working_directory rewrite, in plain strings

working_directory is not a separate argument on the child process. It is rewritten into the command itself, as a cd prefix that matches the chosen shell. Here is what you wrote on the left, and what the shell actually received on the right.

Python call -> what the shell actually runs

# What YOUR Python looks like
await desktop.run(
    "Get-ChildItem -Filter '*.log' | Measure-Object -Property Length -Sum",
    shell="powershell",
    working_directory="C:\\logs",
)
-100% prefixes added by Terminator

Because the cd is part of the command string, the cwd is scoped to this one call. You can alternate working directories across consecutive await desktop.run(...) calls without leaking state between them, and no background process is holding onto a stale pwd.

What crosses the boundary when you await

The PyO3 wrapper at desktop.rs:181 uses pyo3_tokio::future_into_py_with_locals, which releases the GIL and awaits the core Rust run as a tokio task. Rust builds the final command string (including the cd prefix), spawns the shell via tokio::process::Command, captures stdout and stderr on separate pipes, and returns them back through the awaitable.

desktop.run(command, shell, working_directory) end to end

Python command
shell=
working_directory=
desktop.run()
exit_status
stdout
stderr
same Desktop object

The call, sequenced across five actors

Each arrow is a real transition in the code path. The one worth staring at is the third: Rust builds the cd-prefixed string before the child process is spawned. That is how working_directory reaches the child without touching the CreateProcess API.

await desktop.run(cmd, 'powershell', 'C:\\build')

Python async defPyO3 wrapperRust Desktop::runTokio::CommandPowerShell.exerun(cmd, 'powershell', 'C:\\build')release GIL, await core run()build "cd 'C:\\build'; cmd" stringspawn pwsh processstdout / stderr / exit_codeCommandOutput {status, stdout, stderr}map to Python objectreturn to awaitable

What the returned CommandOutput looks like from Python

Three fields. Declared once in packages/terminator-python/src/types.rs:65. Use exit_status as the source of truth, not the stdout text.

command_output.py

Numbers worth remembering

0pyo3 getters on CommandOutput (exit_status, stdout, stderr)
0shell values understood on Windows: powershell, pwsh, cmd, bash, sh
0method on the Desktop object, not a separate module
0line in lib.rs where run() begins its signature

Every shell string the switch understands

The match arms live at crates/terminator/src/lib.rs:588-594 for Windows and lib.rs:604-608 for Unix. Anything not listed falls through to bare execution on the default interpreter for the current OS.

shell="powershell" (Windows default)shell="pwsh" (PowerShell 7+)shell="cmd" (cmd /c "...")shell="bash" (bash -c "...")shell="sh" (sh -c "...")shell=None (falls back per platform)shell="python" (Unix: python -c)shell="node" (Unix: node -e)

Install and prove it works

One pip install, one one-liner that asks PowerShell what day it is and prints the answer and the exit_status.

powershell

A realistic mixed automation in one async def

The shape you reach for once you stop thinking of the shell as a separate module. Close the old app via UIA. Rebuild with PowerShell inside the build directory. Reopen the app and click Connect. No subprocess, no tempfile, no shell-quote helper.

mixed_automation.py

What running it actually prints

Output from a real run with log_level="info". The [desktop.run] lines show the exact string Terminator built before it was handed to pwsh, cd prefix included.

powershell (log_level=info)

Six things you get for free

Everything that distinguishes this from opening a subprocess and hoping for the best.

One method on the Desktop you already have

desktop.run(command, shell=None, working_directory=None) lives on the same object that opens applications and builds locators. Your UI automation script does not grow a second import.

PowerShell is the Windows default

shell.unwrap_or("powershell") at lib.rs:577. When shell is not given on Windows, you land in pwsh. cmd is available by asking for it explicitly.

Auto-cd for working_directory

Passing working_directory='C:\\build' with shell='powershell' rewrites the command as cd 'C:\\build'; <command>. cmd gets cd /d "CWD" && <command>.

Multi-line scripts, verbatim

Triple-quoted PowerShell blocks with $ErrorActionPreference, pipelines, and try/catch go straight to the shell unaltered. The library does not parse or rewrite your command beyond the optional cd prefix.

Awaitable, not blocking

The binding uses pyo3_tokio::future_into_py_with_locals (desktop.rs:181), which releases the GIL so other Python threads keep running while the shell command executes.

Typed return type

CommandOutput exposes three pyo3 getters: exit_status: Optional[int], stdout: str, stderr: str. Declared at packages/terminator-python/src/types.rs line 65.

Three callouts from the source

0

line in crates/terminator/src/lib.rs where the async pub async fn run signature begins.

0

line where PowerShell gets its cd 'CWD'; command rewrite. cmd's cd /d "CWD" && form is on line 580.

0

line where CommandOutput is declared in types.rs. Three pyo3 getters: exit_status, stdout, stderr.

run versus run_command, side by side

Both exist. They serve different jobs. Use run_command when you genuinely need a different command string per OS. Use run when the target is one shell and you want to set it explicitly.

Featuredesktop.run_command(...)desktop.run(...)
Signaturerun_command(windows_command?, unix_command?)run(command, shell=None, working_directory=None)
Cross-platform dispatchYou pass both strings; library picks by OSOne string, one shell name, platform-agnostic if you stay in bash/pwsh
Shell selectionImplicit: Windows cmd, Unix bashExplicit: powershell, pwsh, cmd, bash, sh (lib.rs:588-594)
working_directory supportNot in the call; you cd inside the command yourselfRewritten as cd 'cwd'; command before execution
Multi-line commandsPossible but not idiomaticFirst-class; paste the PowerShell block verbatim
Return typeCommandOutput(exit_status, stdout, stderr)Same CommandOutput
Best forOne-off cross-platform glue that must branch on OSWindows-first automations, CI-style recipes, and bash-first Linux flows

Terminator versus subprocess + pywinauto

The real contrast is not between two libraries; it is between carrying two mental models in one script (UI client plus shell launcher) and carrying one.

Featuresubprocess + pywinautoterminator-py
Import graph for shell + UI automationsubprocess + pywinauto + pyautogui, three APIsterminator.Desktop alone
Default shell on Windowssubprocess picks cmd or the executable you namePowerShell, because lib.rs:577 defaults to it
Async integrationsubprocess.run blocks; asyncio.subprocess has its own setupawait desktop.run(...) on the same event loop as UI actions
Working directorycwd kwarg on subprocess, or cd inside shell=Trueworking_directory='C:\\x' rewrites to cd 'C:\\x'; ...
Multi-line PowerShell scriptsSet shell=True, escape quoting, prayTriple-quoted Python string, unmodified, straight to pwsh
Error stream separationYou opt in with PIPEs; forgetting merges themCommandOutput.stderr always separate from .stdout
Can drive a GUI in the same functionNo: subprocess has no UI treeYes: same Desktop object exposes locator, click, type_text

Want to see your PowerShell glue and your UI automation collapse into one async def?

Book 20 minutes. We will wire terminator-py into a Windows Python workflow of your choice and rip out the subprocess import.

Frequently asked questions

Where is desktop.run() actually defined?

The Python method is at packages/terminator-python/src/desktop.rs line 173, with the pyo3 decorator text_signature = "($self, command, shell=None, working_directory=None)". That wrapper drops the GIL and awaits the core Rust implementation at crates/terminator/src/lib.rs line 569, which takes &str for command, Option<&str> for shell, and Option<&str> for working_directory. The return type CommandOutput is declared in packages/terminator-python/src/types.rs line 65 with three pyo3 getters: exit_status: Optional[int], stdout: str, stderr: str. The same struct reaches Python as a native class.

What shell runs if I pass shell=None on Windows?

PowerShell. The selection line at lib.rs:577 reads shell.unwrap_or("powershell"). If your machine has pwsh.exe (PowerShell 7+), pass shell="pwsh" to route there; the switch at lib.rs:592 treats powershell and pwsh the same and runs the command bare, no -Command wrapping. shell="cmd" wraps the command in cmd /c "...". shell="bash" wraps it in bash -c "...", with internal double quotes escaped. shell="sh" does the same with sh -c. Any other value falls through to bare execution on the default interpreter.

What does working_directory actually do?

It is rewritten into a cd prefix inside your command string before it is handed to the OS. On PowerShell the transformation is `cd 'CWD'; YOUR_COMMAND` (lib.rs:581). On cmd it is `cd /d "CWD" && YOUR_COMMAND` so the /d lets you change drives in one step. There is no separate working-directory argument passed to CreateProcess; the cd is part of the command itself. The practical consequence is that the cwd is scoped per call, not per process, so you can alternate working directories across calls without leaking state.

Can I pass a multi-line command?

Yes. The Python binding takes command as a str, and lib.rs feeds that str straight to the shell. Triple-quoted PowerShell scripts with pipelines, if blocks, and try/catch all work, as long as they are valid in the chosen shell. The library does not parse or rewrite your command beyond the optional cd prefix. For PowerShell multi-line scripts that set $ErrorActionPreference, import modules, and call cmdlets, you do not need a here-string; a regular Python triple-quoted string is fine.

How is this different from subprocess.run or pywinauto.application.Application().start()?

subprocess.run blocks the event loop unless you wrap it in a thread, has no opinion about which shell you want, and does not integrate with your UI automation graph. desktop.run is awaitable, picks PowerShell by default on Windows (the shell Windows admins already write in), and lives on the same Desktop object you use to click buttons, so one async function can interleave UI actions and shell calls without two import graphs. pywinauto's start is for launching an app under its UIA control, not executing a general shell command. terminator.Desktop.open_application is the analogue; desktop.run is for arbitrary shell work.

What is the difference between desktop.run and desktop.run_command?

run_command exists for cases where you want a different command on Windows vs Unix. Its Python signature is run_command(windows_command: Optional[str], unix_command: Optional[str]) at terminator.pyi; the library picks whichever matches the current OS. run is the GitHub-Actions-style entry point: one command string plus an optional shell name and working_directory. For cross-platform automations, use run_command with explicit branches. For Windows-first workflows where PowerShell is the target, use run and pass shell="powershell" (or rely on the default).

What does the CommandOutput I await look like?

Three fields, defined at packages/terminator-python/src/types.rs line 65. exit_status is Optional[int]; it is None if the OS could not report a code, otherwise the process's integer exit status. stdout and stderr are both str and are already decoded; you do not need to call .decode() on them. Because the Rust struct derives Serialize, str(output) gives a human-readable repr. In an async def you write out = await desktop.run(...); assert out.exit_status == 0 just like you would with any other awaitable.

Does the GIL block while the shell command runs?

No. The run binding uses pyo3_tokio::future_into_py_with_locals at desktop.rs:181, which releases the GIL for the duration of the Tokio task. Other Python threads run. The Rust side spawns the child process through tokio::process::Command in the engine's run_command implementation and awaits its output without blocking the tokio runtime. From Python this means you can kick off a slow PowerShell script and keep driving the UI with .click() and .type_text() on a different thread.

What about stderr? Does it come back separate from stdout?

Yes. CommandOutput.stdout and CommandOutput.stderr are independent strings. The Rust engine captures them as separate pipes and hands them back in the struct, so a PowerShell script that writes to the error stream (Write-Error, or a cmdlet that throws) lands in .stderr, not mixed into .stdout. Combined with exit_status, that is enough to classify a failure without parsing text.

terminatorDesktop automation SDK
© 2026 terminator. All rights reserved.