Behind the PowerShell Pipeline logo

Behind the PowerShell Pipeline

Subscribe
Archives
June 27, 2024

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.

Get a premium subscription for full article and archive access
(c) 2022-2025 JDH Information Technology Solutions, Inc. - all rights reserved
Don't miss what's next. Subscribe to Behind the PowerShell Pipeline:
Start the conversation:
GitHub Bluesky LinkedIn About Jeff
Powered by Buttondown, the easiest way to start and grow your newsletter.