How to capture or redirect Write-Host output in powershell
Apr 26, 2012Write-Host, unlike most cmdlets, does not return anything to the pipeline. It merely prints your object or message to the console. That makes it the go-to cmdlet for the ever-so-common task of tracing out status messages. Indeed, its defining feature is that it won’t screw up the return values from functions or scripts, since it emits nothing extra to the pipeline. Unintentionally emitting trace messages (or just stuff in general) to the pipeline is a classic powershell n00b bug, so having such a cmdlet to do the task properly is key.
But what if you actually WANT to process those Write-Host messages? e.g. redirect them to a file, filter them, parse them, etc? Then you are out of luck, since the whole point of the cmdlet is to only print stuff, not return it to the pipeline.
Binging/Googling for a workaround to this problem only leads to a few not-so-great solutions:
- Don’t use Write-Host, use Write-Output (this is good advice but not particularly helpful if you don’t own the code)
- Use Start-Transcript (hmm, sort of)
- One guy on StackOverflow who suggest using a proxy function for Write-Host (but doesn’t go into the details)
Solution #3 actually is a great option, but the guy does not provide any pointers on how to do it. Here’s my implementation.
The approach is to use cmdlet proxies to create a custom version of Write-Host, which will effectively hide the “real” cmdlet. Functions or scripts which call Write-Host will end up calling our version, and we can implement whatever behavior we want. In particular, we will send input to the pipeline rather than simply print it (though we can optionally still print it, too).
We can package this into a single function Select-WriteHost
, and support two basic usage patterns:
# "scriptblock" usage - capture output from the specified scriptblock
$output = Select-WriteHost { .\MyScriptWhichUsesWriteHost.ps1 }
# "inline" usage - capture and pass along output in the current pipeline
$output = .MyScriptWhichUsesWriteHost.ps1 | Select-WriteHost
Below is the code:
function Select-WriteHost
{
[CmdletBinding(DefaultParameterSetName = 'FromPipeline')]
param(
[Parameter(ValueFromPipeline = $true, ParameterSetName = 'FromPipeline')]
[object] $InputObject,
[Parameter(Mandatory = $true, ParameterSetName = 'FromScriptblock', Position = 0)]
[ScriptBlock] $ScriptBlock,
[switch] $Quiet
)
begin
{
function Cleanup
{
# clear out our proxy version of write-host
remove-item function:write-host -ea 0
}
function ReplaceWriteHost([switch] $Quiet, [string] $Scope)
{
# create a proxy for write-host
$metaData = New-Object System.Management.Automation.CommandMetaData (Get-Command 'Microsoft.PowerShell.Utility\Write-Host')
$proxy = [System.Management.Automation.ProxyCommand]::create($metaData)
# change its behavior
$content = if($quiet)
{
# in quiet mode, whack the entire function body, simply pass input directly to the pipeline
$proxy -replace '(?s)\bbegin\b.+', '$Object'
}
else
{
# in noisy mode, pass input to the pipeline, but allow real write-host to process as well
$proxy -replace '($steppablePipeline.Process)', '$Object; $1'
}
# load our version into the specified scope
Invoke-Expression "function ${scope}:Write-Host { $content }"
}
Cleanup
# if we are running at the end of a pipeline, need to immediately inject our version
# into global scope, so that everybody else in the pipeline uses it.
# This works great, but dangerous if we don't clean up properly.
if($pscmdlet.ParameterSetName -eq 'FromPipeline')
{
ReplaceWriteHost -Quiet:$quiet -Scope 'global'
}
}
process
{
# if a scriptblock was passed to us, then we can declare
# our version as local scope and let the runtime take it out
# of scope for us. Much safer, but it won't work in the pipeline scenario.
# The scriptblock will inherit our version automatically as it's in a child scope.
if($pscmdlet.ParameterSetName -eq 'FromScriptBlock')
{
. ReplaceWriteHost -Quiet:$quiet -Scope 'local'
& $scriptblock
}
else
{
# in pipeline scenario, just pass input along
$InputObject
}
}
end
{
Cleanup
}
}
Some comments/warnings:
- “Scriptblock” usage is safe and functional. Internally we can declare our proxy in local scope, the scriptblock will inherit it, and the runtime will clean it up for us for free. We can capture all Write-Host content from the scriptblock, and we can guarantee no side effects. This is the recommended usage.
- “Inline” usage is cooler (heh), but more dangerous and less functional
- If someone earlier in the pipeline uses Write-Host in their “begin” block, we can’t capture it
- The proxy needs to be declared in the global scope in order for other elements in the pipeline to see it. In the happy path, we clean up after ourselves and remove the proxy when we are done. If, however, some error occurs and we can’t clean up, the environment is now polluted with a global proxy for a common cmdlet. That’s bad news.
- As a result of above, elements downstream in the pipeline will get stuck using the proxy, as well, not just upstream elements. This isn’t too bad, but it’s not great, either.
So, armed with our new function, we can now do stuff like this:
# assign to a variable, allow console printing to also happen
PS> $test = 'a','b','c' |%{ Write-Host $_ } | Select-WriteHost
a
b
c
PS> $test
a
b
c
# suppress output via -Quiet switch, and do some filtering
PS> $test = 1..10 |%{ Write-Host "Number is $_" } | Select-WriteHost -Quiet |?{$_ -like "Number is 1*"}
PS> $test
Number is 1
Number is 10
# save transcript of script execution to a log
PS> Select-WriteHost { .\MyScript.ps1 } | Out-File .\ExecutionLog.log
And here’s how to see the potential issue with inline usage:
# look at default write-host command
PS> Get-Command Write-Host
Capability Name ModuleName
---------- ---- ----------
Cmdlet Write-Host Microsoft.PowerShell.Utility
# hit CTRL-C to abort execution midway through
PS> $test = 1..9999 |%{ Write-Host $_ } | Select-WriteHost
# now verify the proxy has polluted the environment since it wasn't cleaned up
PS> Get-Command Write-Host
Capability Name ModuleName
---------- ---- ----------
Cmdlet, Script Write-Host
# try the same thing with scriptblock usage
PS> $test = Select-WriteHost { 1..9999 |%{ Write-Host $_ } }
# we see that the runtime cleans out the proxy for us
PS> Get-Command Write-Host
Capability Name ModuleName
---------- ---- ----------
Cmdlet Write-Host Microsoft.PowerShell.Utility
# as a safeguard, can always do cleanup manually
PS> Remove-Item function:\Write-Host -ea 0