Handling multiple auto-incrementing progress bars in PowerShell

When writing scripts it is often useful to know which step is currently being executed. We can do this by periodically printing a status message to the screen but PowerShell provides a more elegant way in the form of the Write-Progress cmdlet. Using this cmdlet we can print the current status to the screen only to have it replaced by the next status when we move on to the next task. However, the basic use of this cmdlet can become difficult to maintain as scripts expand. Ideally we would prefer to have an auto-incrementing progress bar which can track the number of steps already completed.

We can demonstrate the basic behaviour with the following code:

Write-Progress -Activity "MyActivity" -Status "Performing actions" -PercentComplete 25
Start-Sleep 0.25
Write-Progress -Activity "MyActivity" -Status "Performing actions" -PercentComplete 50
Start-Sleep 0.25
Write-Progress -Activity "MyActivity" -Status "Performing actions" -PercentComplete 75
Start-Sleep 0.25
Write-Progress -Activity "MyActivity" -Status "Performing actions" -PercentComplete 100
Start-Sleep 0.25

Here, create a progress bar and increment by 25 percent every 0.25 seconds. In real scripts you wouldn’t sleep between steps, this is just for demonstration purposes and would be replaced by your actual tasks.

If we want to have multiple progress bars (perhaps a main bar for the overall script progress and then additional bars for sub-tasks, we just need to provide an -Id parameter to differentiate between the bars. If we don’t provide this then the second one to be created will replace the first one.

Write-Progress -Activity "MyActivity" -Status "Performing actions" -PercentComplete 25 -Id 0
Write-Progress -Activity "MySecondActivity" -Status "Performing more actions" -PercentComplete 25 -Id 1
Start-Sleep 0.25
Write-Progress -Activity "MyActivity" -Status "Performing actions" -PercentComplete 50 -Id 0
Write-Progress -Activity "MySecondActivity" -Status "Performing more actions" -PercentComplete 50 -Id 1
Start-Sleep 0.25
Write-Progress -Activity "MyActivity" -Status "Performing actions" -PercentComplete 75 -Id 0
Write-Progress -Activity "MySecondActivity" -Status "Performing more actions" -PercentComplete 75 -Id 1
Start-Sleep 0.25
Write-Progress -Activity "MyActivity" -Status "Performing actions" -PercentComplete 100 -Id 0
Write-Progress -Activity "MySecondActivity" -Status "Performing more actions" -PercentComplete 100 -Id 1
Start-Sleep 0.25

A disadvantage of this method is that we need to manually specify the percentage complete, meaning that if we change our code, we will need to remember to update how the progress bars are generated. In his blog, Adam Bertram provides a fantastic way to automatically calculate the percentage completion using the PsParser .NET class. I’d highly recommend reading Bertram’s article as it provides a lot of background about how PsParser works that I won’t repeat here. To summarise Adam’s method, he simply parses the script file and counts how many calls are made to Write-Progress and treats this as the number of steps the progress bar needs to handle. For scripts containing only a single progress bar this is fine but if we want to track the progress of multiple tasks we will need something more advanced.

Creating a ProgressBar class

As we are looking to create multiple progress bars which need to keep track of their own internal state (specifically the number of steps completed) we can create a class from which we can generate as many progress bars as we need.

class ProgressBar {

    $completedSteps = 0
    $activity
    $id = 0
    $totalSteps

    ProgressBar(
        [string]$activity,
        [int]$id
    ){
        $this.activity = $activity
        $this.id = $id
    }

    [void] UpdateProgress($status) {
        $percentComplete = ($this.completedSteps / $this.totalSteps) * 100
        Write-Progress -Activity $this.activity -Status $status -PercentComplete $percentComplete -Id $this.id
        $this.completedSteps += 1
    }  
}

This code doesn’t yet automatically count the total number of steps, but once it is provided, it will do most of the rest of the work of tracking the progress. Whenever the UpdateProgress method is called, it will update the status message and increment the completed steps by 1. It will also automatically calculate the percentage completion.

To use this class to create a progress bar we need to create an instance:

[ProgressBar]::new("MyProgressBar",1)

We can now call the UpdateProgress method on this object to create and increment the progress bar:

$progressbar.UpdateProgress("Task1")
Start-Sleep 0.25
$progressbar.UpdateProgress("Task2")
Start-Sleep 0.25
$progressbar.UpdateProgress("Task3")
Start-Sleep 0.25
$progressbar.UpdateProgress("Task4")
Start-Sleep 0.25
$progressbar.UpdateProgress("Task5")
Start-Sleep 0.25
$progressbar.UpdateProgress("Task6")
Start-Sleep 0.25
$progressbar.UpdateProgress("Task7")
Start-Sleep 0.25
$progressbar.UpdateProgress("Task8")
Start-Sleep 0.25
$progressbar.UpdateProgress("Task9")
Start-Sleep 0.25
$progressbar.UpdateProgress("Task10")
Start-Sleep 0.25

If we wanted to create a second progress bar we could simply create a second instance and we would then be able to update the two progress bars independently. We would of course need to make sure both bars have separate ID fields to allow them to be shown independently.

Detecting the number of steps

To detect the number of steps we do something similar to Bertram’s method but instead of counting the number of calls to Write-Progress we need to count the calls to our custom ProgressBar object. I was unable to find a reliable way to determine the variable names we choose for our progress bars so we need to add an additional property to our class to store the name of the variable. We simply add a $variableName property to the class and add it to the constructor so we can use it when creating an object.

Once we have this, we can use Bertram’s method to locate instances of our progress bar variable. One issue I had getting this to work is that $($MyInvocation.MyCommand.Name)doesn’t appear to populate correctly when called inside a class definition so we need to save the script path as a variable outside of the class:

$ScriptFile = Get-Content "$PSScriptRoot\$($MyInvocation.MyCommand.Name)"

We can then scan the script and automatically set the value of $totalSteps:

[int]$totalSteps = ([System.Management.Automation.PsParser]::Tokenize(( $ScriptFile ), [ref]$null) | Where-Object { $_.Type -eq 'Variable' } | Where-Object { $_.Content -eq $variableName }).Count - 1

Putting it all together

Now we are able to detect the names of our progress trackers and scan the script for instances of it, we can put it all together.

Defining our auto-incrementing ProgressBar class

$ScriptFile = Get-Content "$PSScriptRoot\$($MyInvocation.MyCommand.Name)"

class ProgressBar {

    [int]$completedSteps = 0
    [string]$activity
    [int]$id
    [string]$variableName
    [int]$totalSteps = ([System.Management.Automation.PsParser]::Tokenize(( $ScriptFile ), [ref]$null) | Where-Object { $_.Type -eq 'Variable' } | Where-Object { $_.Content -eq $variableName }).Count - 1

    ProgressBar(
        [string]$activity,
        [int]$id,
        [string]$variableName
    ){
        $this.activity = $activity
        $this.id = $id
        $this.variableName = $variableName
    }

    [void] UpdateProgress($status) {
        $percentComplete = ($this.completedSteps / $this.totalSteps) * 100
        Write-Progress -Activity $this.activity -Status $status -PercentComplete $percentComplete -Id $this.id
        $this.completedSteps += 1
    }
}

Creating our auto-incrementing progress bar objects

$progressbar = [ProgressBar]::new("MyProgressBar",1,"progressbar")
$progressbar2 = [ProgressBar]::new("MySecondProgressBar",2,"progressbar2")

Note that the third argument is the name of the variable we will use to refer to the progress bar. This is how we determine the number of steps our progress bar needs.

Performing our tasks

Progress bar ilustration in Windows PowerShell 5.1

The following are some example tasks simply to demonstrate how the progress bar can be updated. In a real script, the progressbar and progressbar2 objects need not be updated at the same time and you would perform actual tasks instead of sleeping.

$progressbar.UpdateProgress("Task")
$progressbar2.UpdateProgress("Task2")
Start-Sleep 1
$progressbar.UpdateProgress("Task")
$progressbar2.UpdateProgress("Task2")
Start-Sleep 1
$progressbar.UpdateProgress("Task")
$progressbar2.UpdateProgress("Task2")
Start-Sleep 1
$progressbar.UpdateProgress("Task")
$progressbar2.UpdateProgress("Task2")
Start-Sleep 1
$progressbar.UpdateProgress("Task")
$progressbar2.UpdateProgress("Task2")
Start-Sleep 1
$progressbar.UpdateProgress("Task")
$progressbar2.UpdateProgress("Task2")
Start-Sleep 1
$progressbar.UpdateProgress("Task")
$progressbar2.UpdateProgress("Task2")
Start-Sleep 1