PowerShell Potluck: June 2024
Time to wrap up another month of PowerShell fun. In North America, we're heading into the the middle of summer. My part of the country promises to be an oven this week. So let's chill with some cool PowerShell tidbits, trivia, and tools.
Using Namespace
Many of the articles from this month were predicated on using native .NET Framework classes in your scripts. Writing code with these classes can be tedious. One solution is to incorporate the Using Namespace
statement. This can simplify your code. Here's an example.
Let's say I want to create a generic list object. While this is clear, it is a lot to type.
$list = [System.Collections.Generic.List[string]]::New()
The alternative is to tell PowerShell to use the parent namespace. Put the directive at the beginning of your script file.
Using Namespace System.Collections.Generic
Now, you can use code like this:
$list = [List[string]]::New()
I think this makes the code a little easier to read. Here's a complete example.
#requires -version 7.4
Using Namespace System.Collections.Generic
Using Namespace System.IO
Function Get-ExtensionReport {
[cmdletbinding()]
[OutputType('psExtensionReport')]
[alias('ger')]
Param(
[Parameter(
Position = 0,
ValueFromPipeline,
HelpMessage = 'Specify a folder path like C:\Scripts'
)]
[ValidateNotNullOrEmpty()]
[ValidateScript({ Test-Path $_ }, ErrorMessage = 'Could not verify the specified path.')]
[string]$Path = '.',
[Parameter(
Position = 1,
HelpMessage = 'Specify a file extension like .ps1. DO NOT include the wildcard character.'
)]
#[ValidatePattern('^\.\S+$')]
[ValidateScript({ $_ -match '^\.\S+$' }, ErrorMessage = 'Extension must start with a period and contain non-whitespace characters.')]
[string]$Extension = '.ps1',
[Parameter(HelpMessage = "Enter a wildcard pattern for files to exclude. For example, '*test*'")]
[string]$Exclude,
[switch]$Recurse
)
Begin {
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Starting $($MyInvocation.MyCommand)"
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Running under PowerShell version $($PSVersionTable.PSVersion)"
$FileList = [List[FileInfo]]::new()
if ($Recurse) {
$Opt = [SearchOption]::AllDirectories
}
else {
$Opt = [SearchOption]::TopDirectoryOnly
}
} #begin
Process {
#convert the path into a filesystem path
$Path = Convert-Path $Path
$Folder = [DirectoryInfo]::new($Path)
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Processing $Path"
$Folder.EnumerateFiles("*$Extension", $Opt) | ForEach-Object {
$FileList.Add($_.FullName)
}
if ($Exclude) {
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Excluding files with pattern $Exclude"
$FileList.FindAll({$args.name -like "*test*"}) |
ForEach-Object {[void]$FileList.Remove($_)}
}
if ($FileList.count -gt 0) {
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Found $($FileList.Count) files with extension $Extension"
$Measure = $FileList | Measure-Object -Property length -Sum -Maximum -Minimum -Average
$Oldest = $FileList | Sort-Object LastWriteTime | Select-Object -First 1
$Newest = $FileList | Sort-Object LastWriteTime -Descending | Select-Object -First 1
[PSCustomObject]@{
PSTypeName = 'psExtensionReport'
Path = $Path
Extension = $Extension
TotalFiles = $Measure.Count
TotalSize = $Measure.Sum
AverageSize = $Measure.Average
LargestFile = $Measure.Maximum
SmallestFile = $Measure.Minimum
OldestFile = $Oldest
NewestFile = $Newest
}
}
else {
Write-Warning "No files found with extension $Extension in $Path"
}
} #process
End {
Write-Verbose "[$((Get-Date).TimeOfDay) END ] Ending $($MyInvocation.MyCommand)"
} #end
} #close Get-ExtensionReport
The Using Namespace
statements must be the first lines of code in your script file. You might think you need to put them in the Begin
block. But if you do, you'll get an error:
A 'using' statement must appear before any other statements in a script.
With these statements, my code to create a DirectoryInfo
object, becomes much easier.
$Folder = [DirectoryInfo]::new($Path)
I'm using the .NET Framework to enumerate files to improve performance.
Without the Using
statement, I'd have to use:
$Folder = [System.IO.DirectoryInfo]::new($Path)
Although technically, PowerShell is implicitly using the System
namespace so I could get by with this:
$Folder = [IO.DirectoryInfo]::new($Path)
I am also creating a generic List object.
$FileList = [List[FileInfo]]::New()
I like to think the Using
statements double as documentation.
Here's the function in action.
PS C:\> Get-ExtensionReport c:\work -Verbose -Recurse -Exclude *test*
VERBOSE: [11:37:15.3812937 BEGIN ] Starting Get-ExtensionReport
VERBOSE: [11:37:15.3815952 BEGIN ] Running under PowerShell version 7.4.2
VERBOSE: [11:37:15.3825109 PROCESS] Processing C:\work
VERBOSE: [11:37:15.3928791 PROCESS] Excluding files with pattern *test*
VERBOSE: [11:37:15.3938542 PROCESS] Found 73 files with extension .ps1
Path : C:\work
Extension : .ps1
TotalFiles : 73
TotalSize : 453643
AverageSize : 6214.28767123288
LargestFile : 68576
SmallestFile : 28
OldestFile : C:\work\demos\New-ConditionalDiskChart.ps1
NewestFile : C:\work\PSGalleryReport\scripts\top-authorreport.ps1
VERBOSE: [11:37:15.4025546 END ] Ending Get-ExtensionReport
I think the more complex the namespace, the more benefit you'll see with Using namespace
. You can also take a look at the about_using
help topic.
Formatting TimeSpans
Here's a topic that I encounter often in my scripting work. It is not uncommon that I need to work with TimeSpan
objects.
$ps = Get-Process -Id $pid
$ts = New-TimeSpan -Start $ps.StartTime -End (Get-Date)
By default, PowerShell shows the Duration
property.
PS C:\> $ts
Days : 0
Hours : 2
Minutes : 23
Seconds : 57
Milliseconds : 744
Ticks : 86377440017
TotalDays : 0.0999738889085648
TotalHours : 2.39937333380556
TotalMinutes : 143.962400028333
TotalSeconds : 8637.7440017
TotalMilliseconds : 8637744.0017
When you use the TimeSpan
in a string, PowerShell will automatically invoke the ToString()
method.
PS C:\> $ts.ToString()
02:23:57.7440017
PS C:\> "PowerShell has been running for $ts"
PowerShell has been running for 02:23:57.7440017
In my case, I typically want to get rid or or at least truncate the milliseconds value. I rarely need that level of precision and trimming that value cleans up the output.
While you could parse the string, the better PowerShell way, is to use the .NET format operator, -f
. At its simplest, this is a replacement operator. Define a string with a set of placeholders on the left side of the operator. On the right side, specify a comma separated list of replacements.
PS C:\> "[{0}] The user {1} is logged on to {2}" -f ((Get-Date).TimeOfDay), $env:USERNAME, $env:COMPUTERNAME
[13:09:05.0216720] The user Jeff is logged on to PROSPERO
However, you can format values using case-sensitive qualifiers. Let's say I don't want to see the milliseconds portion of the time. I'll show you how this applies to TimeSpans
in a moment. I can add qualifiers like this::
PS C:\> "[{0:dd\.hh\:mm}] The user {1} is logged on to {2}" -f ((Get-Date).TimeOfDay), $env:USERNAME, $env:COMPUTERNAME
[00.13:13] The user Jeff is logged on to PROSPERO
For DateTime
values you can use these qualifiers:
- dd Days
- hh Hours
- mm Minutes
- ss Seconds
- ffff milliseconds
These are case-sensitive. I like using the values I'm showing here because hh
will force a two digit value. If the number of hours is less than 10, I'll get a value like 03
. In my example, I am escaping the period and semicolon so that they are treated literally.
Let's apply this to my TimeSpan
needs.
PS C:\> '{0} [{2}] has been running for {1:dd\.hh\:mm\:ss}' -f $ps.ProcessName, $ts, $ps.id
pwsh [34860] has been running for 00.02:23:57
I am formatting the time span to days.hours:minutes:seconds
. I am dropping the milliseconds completely.
Note that with the
-f
operator, the placeholders on the left don't have to be in order, as long as they corresponding values on the right are in the correct order.
But perhaps I want the milliseconds value, but only to two places.
PS C:\> '{0} [{2}] has been running for {1:dd\.hh\:mm\:ss\:ff}' -f $ps.ProcessName, $ts, $ps.id
pwsh [34860] has been running for 00.02:23:57:74
There's no limit to how you might format the data.
PS C:\> '{0} [{2}] has been running for {1:dd} days {1:hh} hours {1:mm} minutes {1:ss} seconds' -f $ps.ProcessName, $ts, $ps.id
pwsh [34860] has been running for 00 days 02 hours 23 minutes 57 seconds
You could use variable expansion:
PS C:\> "$($ps.ProcessName) [$($ps.ID)] has been running for $($ts.Days) days $($ts.Hours) hours $($ts.Minutes) minutes $($ts.Seconds) seconds"
pwsh [34860] has been running for 0 days 2 hours 23 minutes 57 seconds
But that becomes a little tedious in my opinion.
You can also use the -f
qualifiers in the ToString()
method.
PS C:\> $ts.ToString("dd\.hh\:mm\:ss\:ff")
00.02:23:57:74
Here's a broader example:
PS C:\> Get-Process | Where-Object { $_.StartTime } | Select -first 10 ID, Name,
@{Name='RunningTime';Expression={
$ts = New-TimeSpan -Start $_.StartTime -End (Get-Date)
$ts.ToString("dd\.hh\:mm\:ss\:ff")
}}
Id Name RunningTime
-- ---- -----------
14124 1Password 00.15:56:53:11
22368 1Password 00.15:56:59:54
24300 1Password 00.15:56:27:96
38844 1Password 00.02:36:01:55
31484 1Password-BrowserSupport 00.02:24:11:42
31836 1Password-BrowserSupport 00.04:22:29:16
4440 AggregatorHost 00.16:01:49:91
11772 ApplicationFrameHost 00.15:59:33:62
1460 backgroundTaskHost 00.12:36:06:96
3196 backgroundTaskHost 00.09:06:06:84
PSScriptTools
Earlier this month, I published an update to the PSScriptTools module. There are a few changes that I think might interest you.
Get-ModuleCommand
One of the module functions is an alternative to Get-Command
for listing commands in a module. Normally you would run a command like this to discover the commands in a module.
PS C:\> Get-Command -Module PSReadline
CommandType Name Version Source
----------- ---- ------- ------
Function PSConsoleHostReadLine 2.3.5 PSReadLine
Cmdlet Get-PSReadLineKeyHandler 2.3.5 PSReadLine
Cmdlet Get-PSReadLineOption 2.3.5 PSReadLine
Cmdlet Remove-PSReadLineKeyHandler 2.3.5 PSReadLine
Cmdlet Set-PSReadLineKeyHandler 2.3.5 PSReadLine
Cmdlet Set-PSReadLineOption 2.3.5 PSReadLine
Or you can use the Get-ModuleCommand
function for an enhanced view of the same information.
PS C:\> Get-ModuleCommand -Name PSReadline
ModuleName: PSReadLine [v2.3.5]
Name Alias Synopsis
---- ----- --------
Get-PSReadLineKeyHandler Gets the key bindings for the PSReadLine module.
Get-PSReadLineOption Gets values for the options that can be configured.
PSConsoleHostReadLine PSConsoleHostReadLine
Remove-PSReadLineKeyHandler Removes a key binding.
Set-PSReadLineKeyHandler Binds keys to user-defined or PSReadLine key handler functions.
Set-PSReadLineOption Customizes the behavior of command line editing in PSReadLine .
I especially like being able to discover aliases.
PS C:\> Get-ModuleCommand -Name PSProjectStatus
ModuleName: PSProjectStatus [v0.14.1]
Name Alias Synopsis
---- ----- --------
Get-PSProjectGitStatus gitstat Get git project status.
Get-PSProjectReport Manage all your PSProject folders.
Get-PSProjectStatus gpstat Get project status.
Get-PSProjectTask List project tasks
...
The module must be imported into your session before you can run this command. Otherwise, use the -ListAvailable
parameter.
PS C:\> Get-ModuleCommand -Name PSWorkItem -ListAvailable
ModuleName: PSWorkItem [v1.10.0]
Name Alias Synopsis
---- ----- --------
Add-PSWorkItemCategory Add a new PSWorkItem category
Complete-PSWorkItem cwi Mark a PSWorkItem as complete.
Get-PSWorkItem gwi Get a PSWorkItem.
Get-PSWorkItemArchive Get archived PSWorkItems.
...
CimMember Functions
A major addition to the module are a set of commands that make it easier to display CIM class information. Get-CimClass
is useful, but I often find I need to take extra steps to display what I want to see. The PSScriptTools module offers several CIM-related tools.
PS C:\> Get-ModuleCommand -Name PSScriptTools | where Name -match CIM
ModuleName: PSScriptTools [v2.49.0]
Name Alias Synopsis
---- ----- --------
Find-CimClass fcc Search CIM for a class.
Get-CimClassListing A faster way to list CIM classes in a given namespace.
Get-CimClassMethod Get the methods of a CIM class.
Get-CimClassProperty Get the properties of a CIM class.
Get-CimClassPropertyQualifier Get the property qualifiers of a CIM class.
Get-CimMember cmm Get information about CIM class members
Get-CimNamespace Enumerate WMI/CIM namespaces
Now I can easily view properties of a CIM class.
PS C:\> Get-CimMember Win32_UserProfile
Class: Root/Cimv2:Win32_UserProfile
Property ValueType Flags
-------- --------- -----
AppDataRoaming Instance ReadOnly, NullValue
Contacts Instance ReadOnly, NullValue
Desktop Instance ReadOnly, NullValue
Documents Instance ReadOnly, NullValue
Downloads Instance ReadOnly, NullValue
Favorites Instance ReadOnly, NullValue
HealthStatus UInt8 ReadOnly, NullValue
LastAttemptedProfileDownloadTime DateTime ReadOnly, NullValue
LastAttemptedProfileUploadTime DateTime ReadOnly, NullValue
LastBackgroundRegistryUploadTime DateTime ReadOnly, NullValue
LastDownloadTime DateTime ReadOnly, NullValue
LastUploadTime DateTime ReadOnly, NullValue
LastUseTime DateTime ReadOnly, NullValue
Links Instance ReadOnly, NullValue
Loaded Boolean ReadOnly, NullValue
LocalPath String ReadOnly, NullValue
Music Instance ReadOnly, NullValue
Pictures Instance ReadOnly, NullValue
RefCount UInt32 ReadOnly, NullValue
RoamingConfigured Boolean ReadOnly, NullValue
RoamingPath String ReadOnly, NullValue
RoamingPreference Boolean NullValue
SavedGames Instance ReadOnly, NullValue
Searches Instance ReadOnly, NullValue
SID String Key, ReadOnly, NullValue
Special Boolean ReadOnly, NullValue
StartMenu Instance ReadOnly, NullValue
Status UInt32 ReadOnly, NullValue
Videos Instance ReadOnly, NullValue
Key properties will be highlighted in green. And I can easily see methods.
PS C:\> Get-CimMember Win32_UserProfile -Method *
Class: Root/Cimv2:Win32_UserProfile
Name ResultType Parameters
---- ---------- ----------
ChangeOwner UInt32 {NewOwnerSID, Flags}
The Get-CimMember
function is a wrapper that will invoke Get-CimClassProperty
or Get-CimClassMethod
. Although you can call these functions directly.
The commands are more fully documented in the project's README file.
PowerShell Seeds
Since I just mentioned CIM/WMI, let's wrap up with a little trivia. Microsoft's first attempt at a systems management command-line tool goes all the way back to the days of Windows XP and wmic.exe. This command is still available today, although it is flagged as deprecated.
You can use wmic as a command-line tool.
C:\>wmic baseboard get /format:list
Caption=Base Board
ConfigOptions={"scre++","SMI:00B2C000"}
CreationClassName=Win32_BaseBoard
Depth=
Description=Base Board
Height=
HostingBoard=TRUE
HotSwappable=FALSE
InstallDate=
Manufacturer=LENOVO
Model=
Name=Base Board
OtherIdentifyingInfo=
PartNumber=
PoweredOn=TRUE
Product=3172
Removable=FALSE
Replaceable=TRUE
RequirementsDescription=
RequiresDaughterBoard=FALSE
SerialNumber=
SKU=
SlotLayout=
SpecialRequirements=
Status=OK
Tag=Base Board
Version=SDK0T08861 WIN 3305311422172
Weight=
Width=
Think of this as a scripting language. Or you can enter an interactive shell.
C:\>wmic
wmic:root\cli>os get caption,installdate
Caption InstallDate
Microsoft Windows 11 Pro 20240616213052.000000-240
wmic:root\cli>exit
C:\>
Remind you of anything? If you look at the command, you'll see the seeds of PowerShell. This isn't surprising, because the man behind wmic was Jeffrey Snover. That's right. The inventor of PowerShell.
Summary
I think we can call it a day for now. I hope you learned a few new things this month. If you are not a premium subscriber, I hope you'll consider testing out a subscription for a month or two. You can cancel at any time. Premium members have full access to the back catalog of content going back to the beginning of 2022. If you are a premium member, I'd be very appreciative if you could leave a comment highlighting the value you place on your subscription, or how this newsletter has help you.
Otherwise, stay cool, do good and I'll be back next month.