Skip to content

Instantly share code, notes, and snippets.

@jdhitsolutions
Last active May 29, 2024 13:37
Show Gist options
  • Save jdhitsolutions/a2f3a246c929a91e494601fa1c44fa55 to your computer and use it in GitHub Desktop.
Save jdhitsolutions/a2f3a246c929a91e494601fa1c44fa55 to your computer and use it in GitHub Desktop.
Material from my RTPSUG presentation on writing better PowerShell code

Writing Better PowerShell Code

Often, the difference between writing good PowerShell code and better code is nothing more than experience. The more you write and learn, the better your code becomes. Of course, there’s always the challenge of "I don’t know what I don’t know."

This is my presentation to the Research Triangle PowerShell User Group in April 2024. I wanted to talk about the non-technical aspects of PowerShell scripting. Learning the syntax and mechanics is easy. Knowing what to write, what not to write and how to write it is a little harder. This isn't documented like cmdlet help. The presentation and demos are based on my 18 years(!) of PowerShell scripting experience. The session pulls material from articles published in my premium newsletter, Behind the PowerShell Pipeline.

You can view the session recording on YouTube

All material and code is provided as-is and without warranty. The content of this gist should be considered educational material.

return "This is a demo script file."
# Find Me: https://jdhitsolutions.github.io
# Any one can learn syntax and mechanics.
# Let's focus on the squishy bits - what you should write
# and what not to write
#region Essential rules
# 1. THE POWERSHELL PARADIGM IS USING OBJECTS IN THE PIPELINE
# 2. DON'T MANIPULATE TEXT TO GET YOUR DESIRED RESULT
# 3. LET POWERSHELL DO THE WORK FOR YOU
# 4. THINK ABOUT WHERE YOUR CODE WILL BE RUN
# 5. THINK ABOUT WHERE YOUR OUTPUT WILL CREATED
#endregion
#region Object output
# don't include blank line to make the output look pretty. Write one type of object
Function Get-Widget {
[cmdletbinding()]
Param ()
$r = [PSCustomObject]@{
Name = $env:Username
Computer = $env:COMPUTERNAME
OS = (Get-CimInstance -ClassName Win32_operatingSystem -Property Caption).Caption
LuckyNumber = Get-Random -Minimum 1 -Maximum 20
}
Write-Output ' '
Write-Output $r
Write-Output ' '
}
Get-Widget
Get-Widget | Measure-Object
#don't need write-output
Function Get-Widget {
[cmdletbinding()]
Param ()
$r = [PSCustomObject]@{
Name = $env:Username
Computer = $env:COMPUTERNAME
OS = (Get-CimInstance -ClassName Win32_operatingSystem -Property Caption).Caption
LuckyNumber = Get-Random -Minimum 1 -Maximum 20
}
#no formatting - let PowerShell handle the output
$r
}
Get-Widget -ov a | Measure-Object
$a
#endregion
#region don't compare boolean strings
$x = $true
#no
if ($x -eq $True) {
'I am good'
}
#Think object
#yes
if ($x) {
'I am good'
}
#It doesn't work the way you think
$p = Get-Process -id $pid
$p.Responding
$p.Responding.GetType().Name
if ($p.Responding -eq 'true') { "ok" }
if ($p.Responding -eq 'false') { "ok" }
#better
if ($p.Responding) { "ok" }
if (-Not $p.Responding) { "ok" } else { "not ok"}
#xml data exceptions
[xml]$data = Get-Content proc.xml
$data.processes.item("Process").IsResponding
if ($data.processes.item("Process").IsResponding) {"ok"}
if ($data.processes.item("Process").IsResponding -eq 'True') {"ok"}
[xml]$data2 = Get-Content proc2.xml
$data2.processes.item("Process").IsResponding
#Be careful about seeing objects when they don't exist
if ($data2.processes.item("Process").IsResponding) {"ok"}
$data2.processes.item("Process").IsResponding.GetType().Name
if ($data2.processes.item("Process").IsResponding -eq 'True') {"ok"}
<#
avoid this as well
PS C:\> $x -eq 1
True
PS C:\> $x -eq 0
False
#>
#endregion
#region write rich objects - use formatting to set defaults
Function Resolve-WhoIs {
[CmdletBinding()]
Param(
[Parameter(Position = 0, Mandatory, ValueFromPipeline)]
[string]$IPAddress
)
Begin {
Write-Verbose "Starting $($MyInvocation.MyCommand)"
$baseURL = 'http://whois.arin.net/rest'
}
Process {
Write-Verbose "Resolving IP $IPAddress"
$url = "$baseUrl/ip/$IPAddress"
$r = Invoke-RestMethod $url
#if $r.net exists it is implicitly true
if ($r.net) {
[PSCustomObject]@{
IP = $IPAddress
Name = $r.net.name
RegisteredOrganization = $r.net.orgRef.name
}
}
}
End {
Write-Verbose "Ending $($MyInvocation.MyCommand)"
}
}
Resolve-WhoIs 8.8.8.8
#what would make this richer or more useful?
Function Resolve-WhoIs {
[CmdletBinding()]
Param(
[Parameter(Position = 0, Mandatory, ValueFromPipeline)]
[string]$IPAddress
)
Begin {
Write-Verbose "Starting $($MyInvocation.MyCommand)"
$baseURL = 'http://whois.arin.net/rest'
}
Process {
Write-Verbose "Resolving IP $IPAddress"
$url = "$baseUrl/ip/$IPAddress"
$r = Invoke-RestMethod $url
$global:raw = $r
if ($r.net) {
#measure ping latency
$ping = (test-connection $IPAddress -IPv4 -Ping -Count 3 |
Measure-Object -Property Latency -Average).average -as [int]
[PSCustomObject]@{
PSTypeName = 'PSWhoIs' #<-- this is the key to custom formatting
IP = $IPAddress
Name = $r.net.name
RegisteredOrganization = $r.net.orgRef.Name
OrganizationHandle = $r.net.orgRef.Handle
City = (Invoke-RestMethod $r.net.orgRef.'#text').org.city
StartAddress = $r.net.startAddress
EndAddress = $r.net.endAddress
NetBlocks = $r.net.netBlocks.netBlock | ForEach-Object { "$($_.StartAddress)/$($_.cidrLength)" }
Online = $raw.net.orgref.'#text'
PingLatency = $ping
Updated = $r.net.updateDate -as [DateTime]
}
}
}
End {
Write-Verbose "Ending $($MyInvocation.MyCommand)"
}
}
Resolve-WhoIs 8.8.8.8 | Tee-Object -Variable who
$who | Get-Member
#control the output
# Install-Module PSScriptTools
# https://github.com/jdhitsolutions/PSScriptTools
# I have already run this
# $who | New-PSFormatXML -Path .\pswhois.format.ps1xml -GroupBy RegisteredOrganization -Properties IP,Name,StartAddress,EndAddress -FormatType Table
#I've tweaked the file
psedit .\pswhois.format.ps1xml
# load the format file. The format file requires a PowerShell 7 session
# that supports PSStyle
Update-FormatData .\pswhois.format.ps1xml
#You could also create named custom views and type extensions
#add a custom type method
Update-TypeData -TypeName PSWhoIs -MemberType ScriptMethod -MemberName Open -Value {start $this.Online}
Update-TypeData -TypeName PSWhoIs -MemberType AliasProperty -MemberName Locale -Value City
#Don't do this
Function Resolve-WhoIs2 {
[CmdletBinding()]
Param(
[Parameter(Position = 0, Mandatory, ValueFromPipeline)]
[string]$IPAddress
)
Begin {
Write-Verbose "Starting $($MyInvocation.MyCommand)"
$baseURL = 'http://whois.arin.net/rest'
$out = @()
}
Process {
Write-Verbose "Resolving IP $IPAddress"
$url = "$baseUrl/ip/$IPAddress"
$r = Invoke-RestMethod $url
if ($r.net) {
$out += [PSCustomObject]@{
IP = $IPAddress
Name = $r.net.name
RegisteredOrganization = $r.net.orgRef.name
}
}
}
End {
#VERY BAD 😣 🤮 ☠
$out | Sort-Object -property RegisteredOrganization |
Format-Table -GroupBy RegisteredOrganization -Property Name,IP
Write-Verbose "Ending $($MyInvocation.MyCommand)"
}
}
Resolve-WhoIs2 8.8.8.8
Resolve-WhoIs2 8.8.8.8 | Get-Member
#you don't know how someone, or you, might want to use the output.
#endregion
#region Be careful with Return
#problematic
Function Get-Things {
[cmdletbinding()]
Param(
[int]$Count = 10
)
Begin {
Write-Verbose "begin"
}
Process {
do {
$i = Get-Random -Minimum 1 -Maximum 100
if ($i -ge 50) {
Return $i
}
$count--
} Until ($count -eq 0)
}
end {
Write-Verbose "end"
}
}
Get-Things -Verbose
#better but awkward
Function Get-Things {
[cmdletbinding()]
Param(
[int]$Count = 10
)
Begin {
Write-Verbose "begin"
}
Process {
do {
$i = Get-Random -Minimum 1 -Maximum 100
if ($i -ge 50) {
$i
}
$count--
} Until ($count -eq 0)
}
end {
Write-Verbose "end"
}
}
Get-Things -Verbose
#let the pipeline do its thing
Function Get-Things {
[cmdletbinding()]
Param(
[int]$Count = 10
)
Begin {
Write-Verbose "begin"
}
Process {
Write-Verbose "Getting $Count random numbers between 1 and 100"
Get-Random -Minimum 1 -Maximum 100 -Count $Count | Where-Object {$_ -ge 50}
}
end {
Write-Verbose "end"
}
}
#use when you want to intentionally bail out
Function Invoke-Weekly {
[cmdletbinding()]
Param(
[string]$Path = "C:\work"
)
begin {
Write-Verbose "begin"
$now = Get-Date
}
Process {
Write-Verbose "Processing $Path"
Try {
$items = Get-ChildItem -LiteralPath $Path -file -Recurse -ErrorAction stop
}
Catch {
#this is demonstrating the use of RETURN not the best way to validate parameters
Write-Warning "Failed to validate path. Abandoning process."
#this returns from the Process script block
Return
}
Write-Host "Backing up $($items.count) items" -ForegroundColor Green
Write-Host "Exit Process block" -ForegroundColor Cyan
}
end {
Write-Verbose "end"
}
}
Invoke-Weekly -Verbose
Invoke-Weekly -path X:\FooBar -Verbose
# I inserted a Return statement at the beginning of this script file
# so that I wouldn't accidentally run it.
#endregion
#region use Switch over multiple If/Else statements
# 14393 2016
# 17763 2019
# 22631 Win11
# 19044 Win10
$os = Get-CimInstance Win32_OperatingSystem
if ($os.BuildNumber -eq 22631) {
Write-Host "Running Windows 11 code"
}
elseif ($os.BuildNumber -eq 19044) {
Write-Host "Running Windows 10 code"
}
elseif ($os.BuildNumber -eq 14393) {
Write-Host "Running Windows Server 2016 code"
}
elseif ($os.BuildNumber -eq 17763) {
Write-Host "Running Windows Server 2019 code"
}
#better
Switch ($os.BuildNumber) {
22631 {
Write-Host "Running Windows 11 code"
}
19044 {
Write-Host "Running Windows 10 code"
}
14393 {
Write-Host "Running Windows Server 2016 code"
}
17763 {
Write-Host "Running Windows Server 2019 code"
}
}
#this also allows you to run code based on multiple matches
Function Invoke-FileAction {
[CmdletBinding()]
Param(
[Parameter(Position = 0, Mandatory,ValueFromPipeline)]
[string]$Path
)
Begin {}
Process {
Write-Host "Processing $Path" -ForegroundColor green
$file = Get-Item $Path
Switch ($file) {
{$_.length -ge 1000} { Write-Host "large file operation" -ForegroundColor Cyan }
{$_.Extension -match "\.ps*"} { Write-Host "PowerShell file operation" -ForegroundColor DarkBlue }
{$_.Extension -match "\.((json)|(xml)|(csv))"} { Write-Host "Data file operation" -ForegroundColor yellow }
Default { Write-Host "No action taken" -ForegroundColor magenta}
}
}
End {}
}
Invoke-FileAction -Path .\demo.ps1
Invoke-FileAction -Path .\proc.xml
Help about_Switch
#endregion
#region think locally act remotely
psedit .\getstat-local.ps1
Get-Status
$ps = New-PSSession -computername SRV1
#use the copy of the function remotely
$sb = (Get-Item Function:\Get-Status).scriptblock
Invoke-Command {New-Item -Path function:\get-status -Value $using:sb} -Session $ps
Invoke-Command {Get-Status -AsInt} -session $ps -HideComputerName |
select * -ExcludeProperty runspaceid
psedit .\getstat-remote.ps1
. .\getstat-remote.ps1
Get-SystemStatus -Verbose
Get-SystemStatus -Computername dom1 -Verbose
#this could be rewritten to use an existing PSSession
#endregion
#region Consider module purpose and user
#WHO WILL BE USING YOUR CODE?
#WHAT ARE THEIR EXPECTATIONS?
#HOW WILL THEY USE YOUR COMMANDS
#endregion
#region code writing tips
# FOLLOW COMMUNITY-ACCEPTED BEST PRACTICES
# WRITE VERTICAL CODE
# AVOID REDUNDANT CODE - "COMPONENTIZE" IT
# USE CONSISTENT CASING (CAMELCASE/PASCALCASE)
# STANDARDIZE ON CODE LAYOUT STANDARDS (ESPECIALLY IN A TEAM)
# WRITE RICH OBJECTS TO THE PIPELINE WITH CUSTOM FORMATTING
# USE RETURN KEY WORD INTENTIONALLY
# DON'T ASSUME YOU KNOW HOW YOUR CODE WILL BE USED
#
#endregion
#requires -version 5.1
Function Get-SystemStatus {
[cmdletbinding()]
[alias('gss')]
Param(
[Parameter(HelpMessage = 'Format values as [INT]')]
[switch]$AsInt
)
Begin {
Write-Verbose "[$((Get-Date).TimeOfDay)] Starting $($MyInvocation.MyCommand)"
} #begin
Process {
$CimParams = @{
ErrorAction = 'stop'
ClassName = 'Win32_OperatingSystem'
}
Write-Verbose "[$((Get-Date).TimeOfDay)] Using class $($CimParams.ClassName)"
$OS = Get-CimInstance @CimParams
$pctFreeMem = [math]::Round(($os.FreePhysicalMemory / $os.TotalVisibleMemorySize) * 100, 2)
$CimParams.ClassName = 'Win32_LogicalDisk'
$CimParams.filter = "DeviceID='$($OS.SystemDrive)'"
Write-Verbose "[$((Get-Date).TimeOfDay)] Using class $($CimParams.ClassName)"
$disk = Get-CimInstance @CimParams
$pctFree = [math]::Round(($disk.FreeSpace / $disk.size) * 100, 2)
if ($AsInt) {
$pctFree = $pctFree -as [int]
$pctFreeMem = $pctFreeMem -as [INT]
}
[PSCustomObject]@{
PSTypename = 'QuickSystemStatus'
Computername = $ENV:COMPUTERNAME
Uptime = (Get-Date) - $OS.lastBootUpTime
PctFreeMem = $pctFreeMem
PctFreeC = $pctFree
}
} #process
End {
Write-Verbose "[$((Get-Date).TimeOfDay)] Ending $($MyInvocation.MyCommand)"
} #end
} #close function
#requires -version 5.1
#this is a proof-of-concept function
# this version has been modified since my presentation.
# I have added a ScriptProperty Type extension to the object
Function Get-SystemStatus {
#comment based help goes here
[cmdletbinding()]
[alias('gss')]
Param(
[Parameter(
Position = 0,
ValueFromPipeline,
HelpMessage = 'Enter a computer name'
)
]
[ValidateNotNullOrEmpty()]
[string]$Computername = $env:computername,
[PSCredential]$Credential,
[Parameter(HelpMessage = 'Format values as [INT]')]
[switch]$AsInt
)
Begin {
Write-Verbose "[$((Get-Date).TimeOfDay)] Starting $($MyInvocation.MyCommand)"
#define the scriptblock that will run via Invoke-Command
$getScriptBlock = {
#inherit VerbosePreference
$VerbosePreference = $using:VerbosePreference
Write-Verbose "[$((Get-Date).TimeOfDay)] Starting $($Env:COMPUTERNAME)"
$CimParams = @{
ErrorAction = 'stop'
ClassName = 'Win32_OperatingSystem'
}
$hash = @{
Computername = $ENV:COMPUTERNAME
}
Write-Verbose "[$((Get-Date).TimeOfDay)] $($Env:COMPUTERNAME) Using class $($CimParams.ClassName)"
$OS = Get-CimInstance @CimParams
$pctFreeMem = [math]::Round(($os.FreePhysicalMemory / $os.TotalVisibleMemorySize) * 100, 2)
$CimParams.ClassName = 'Win32_LogicalDisk'
$CimParams.filter = "DeviceID='$($OS.SystemDrive)'"
Write-Verbose "[$((Get-Date).TimeOfDay)] $($Env:COMPUTERNAME) Using class $($CimParams.ClassName)"
$disk = Get-CimInstance @CimParams
$pctFree = [math]::Round(($disk.FreeSpace / $disk.size) * 100, 2)
if ($Using:AsInt) {
$pctFree = $pctFree -as [int]
$pctFreeMem = $pctFreeMem -as [INT]
}
# $hash.Add('Uptime', (Get-Date) - $OS.lastBootUpTime)
$hash.Add('BootTime', $OS.lastBootUpTime)
$hash.Add('PctFree', $pctFree)
$hash.Add('PctFreeMem', $pctFreeMem)
#write the hashtable as the scriptblock output
$hash
Write-Verbose "[$((Get-Date).TimeOfDay)] Finished $($Env:COMPUTERNAME)"
} #close scriptblock
#parameters to splat to Invoke-Command
$icmParams = @{
ErrorAction = 'Stop'
ScriptBlock = $getScriptBlock
HideComputerName = $True
}
if ($Credential.UserName) {
Write-Verbose "[$((Get-Date).TimeOfDay)] Adding an alternate credential for $($Credential.UserName)"
$icmParams.Add('Credential', $Credential)
}
} #begin
Process {
Write-Verbose "[$((Get-Date).TimeOfDay)] Getting status from $($Computername.ToUpper())"
$icmParams.Computername = $Computername
Try {
$r = Invoke-Command @icmParams
#create the output object locally from the remote data so it isn't a deserialized object
#and I can avoid the runspace ID
[PSCustomObject]@{
PSTypename = 'QuickSystemStatus'
Computername = $r.computername
BootTime = $r.BootTime
PctFreeMem = $r.pctFreeMem
PctFreeC = $r.pctFree
}
} #try
Catch {
Write-Warning "Failed to get status from $Computername"
Write-Warning $_.Exception.Message
} #catch
} #process
End {
Write-Verbose "[$((Get-Date).TimeOfDay)] Ending $($MyInvocation.MyCommand)"
} #end
} #close function
#add a script property to the object
Update-TypeData -TypeName QuickSystemStatus -MemberType ScriptProperty -MemberName Uptime -Value {New-TimeSpan -Start $this.BootTime -End (Get-Date)} -Force
#add a script method to the object
Update-TypeData -TypeName QuickSystemStatus -MemberType ScriptMethod -MemberName Ping -Value {test-connection -TargetName $this.computername -IPv4 -Count 2 } -Force
<#
PS C:\> Get-SystemStatus -Computername srv2 | tee -Variable t
Computername : SRV2
BootTime : 4/1/2024 4:18:03 PM
PctFreeMem : 33.61
PctFreeC : 90.56
Uptime : 2.17:05:11.9964731
PS C:\> $t.Ping()
Destination: SRV2
Ping Source Address Latency BufferSize Status
(ms) (B)
---- ------ ------- ------- ---------- ------
1 win11-01 192.168.3.51 0 32 Success
2 win11-01 192.168.3.51 0 32 Success
PS C:\> $t | Get-Member
TypeName: QuickSystemStatus
Name MemberType Definition
---- ---------- ----------
Equals Method bool Equals(System.Object obj)
GetHashCode Method int GetHashCode()
GetType Method type GetType()
ToString Method string ToString()
BootTime NoteProperty datetime BootTime=4/1/2024 4:18:03 PM
Computername NoteProperty string Computername=SRV2
PctFreeC NoteProperty double PctFreeC=90.56
PctFreeMem NoteProperty double PctFreeMem=33.61
Ping ScriptMethod System.Object Ping();
Uptime ScriptProperty System.Object Uptime {get=New-TimeSpan -Start $this…
#>
MIT License
Copyright (c) 2024 JDH Information Technology Solutions, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
<?xml version="1.0" encoding="utf-8"?>
<Processes>
<Process>
<Name>pwsh</Name>
<Handle>4868</Handle>
<Id>25332</Id>
<WorkingSet>553287680</WorkingSet>
<IsResponding>True</IsResponding>
</Process>
</Processes>
<?xml version="1.0" encoding="utf-8"?>
<Processes>
<Process>
<Name>pwsh</Name>
<Handle>4868</Handle>
<Id>25332</Id>
<WorkingSet>553287680</WorkingSet>
<IsResponding>False</IsResponding>
</Process>
</Processes>
<!--
Format type data generated 03/29/2024 16:52:03 by PROSPERO\Jeff
This file was created using the New-PSFormatXML command that is part
of the PSScriptTools module.
https://github.com/jdhitsolutions/PSScriptTools
-->
<Configuration>
<ViewDefinitions>
<View>
<!--Created 03/29/2024 16:52:03 by PROSPERO\Jeff-->
<Name>default</Name>
<ViewSelectedBy>
<TypeName>PSWhoIs</TypeName>
</ViewSelectedBy>
<GroupBy>
<!--This file will only work in PowerShell 7-->
<ScriptBlock>"`e[3;38;5;51m{0}`e[0m [{1}]" -f ($PSStyle.FormatHyperlink($_.RegisteredOrganization,$_.Online)),$_.OrganizationHandle</ScriptBlock>
<Label>RegisteredOrganization</Label>
</GroupBy>
<TableControl>
<!--Delete the AutoSize node if you want to use the defined widths.-->
<AutoSize />
<TableHeaders>
<TableColumnHeader>
<Label>IP</Label>
<Width>10</Width>
<Alignment>left</Alignment>
</TableColumnHeader>
<TableColumnHeader>
<Label>Name</Label>
<Width>7</Width>
<Alignment>left</Alignment>
</TableColumnHeader>
<TableColumnHeader>
<Label>StartAddress</Label>
<Width>15</Width>
<Alignment>right</Alignment>
</TableColumnHeader>
<TableColumnHeader>
<Label>EndAddress</Label>
<Width>13</Width>
<Alignment>right</Alignment>
</TableColumnHeader>
</TableHeaders>
<TableRowEntries>
<TableRowEntry>
<TableColumnItems>
<TableColumnItem>
<PropertyName>IP</PropertyName>
</TableColumnItem>
<TableColumnItem>
<PropertyName>Name</PropertyName>
</TableColumnItem>
<TableColumnItem>
<PropertyName>StartAddress</PropertyName>
</TableColumnItem>
<TableColumnItem>
<PropertyName>EndAddress</PropertyName>
</TableColumnItem>
</TableColumnItems>
</TableRowEntry>
</TableRowEntries>
</TableControl>
</View>
</ViewDefinitions>
</Configuration>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment