A few weeks ago, an Iron Scripter PowerShell scripting challenge was posted. The challenge involved wide directory listings. Which always makes me think "open wide", which leads me to "Open Up Wide" by Chase. (I used to play trumpet and Chase was THE band back in the day). Anyway, solving the challenge most likely involves a combination of Get-ChildItem and Format-Wide. Here's what I came up with.
ManageEngine ADManager Plus - Download Free Trial
Exclusive offer on ADManager Plus for US and UK regions. Claim now!
A Stand-Alone Function
My first solution takes the form of a stand-alone function. Most of the function's parameters are the same as Get-ChildItem. But I also provide a parameter to let the user specify what data to display in the [] portion of the display. Here's the complete function, then I'll point out a few things.
Function Show-DirectoryInfo {
#this version writes formatted data to the pipeline
[cmdletbinding()]
[alias("sw")]
Param(
[Parameter(Position = 0,HelpMessage = "Enter a file system path")]
[ValidateScript({( Test-Path $_ ) -AND ((Get-Item $_).psprovider.name -eq "FileSystem")})]
[string]$Path = ".",
[Parameter(Position = 1,HelpMessage = "What detail do you want to see? Size or Count of files?")]
[ValidateSet("Size", "Count")]
[string]$Detail = "Count",
[switch]$Recurse,
[Int32]$Depth
)
DynamicParam {
if ($Detail -eq "Size") {
#define a parameter attribute object
$attributes = New-Object System.Management.Automation.ParameterAttribute
$attributes.HelpMessage = "Enter a unit of measurement: KB, MB, GB Bytes."
#define a collection for attributes
$attributeCollection = New-Object -Type System.Collections.ObjectModel.Collection[System.Attribute]
$attributeCollection.Add($attributes)
#define an alias
$alias = New-Object System.Management.Automation.AliasAttribute -ArgumentList "as"
$attributeCollection.Add($alias)
#add a validateion set
$set = New-Object -type System.Management.Automation.ValidateSetAttribute -ArgumentList ("bytes", "KB", "MB", "GB")
$attributeCollection.add($set)
#define the dynamic param
$dynParam1 = New-Object -Type System.Management.Automation.RuntimeDefinedParameter("Unit", [string], $attributeCollection)
$dynParam1.Value = "Bytes"
#create array of dynamic parameters
$paramDictionary = New-Object -Type System.Management.Automation.RuntimeDefinedParameterDictionary
$paramDictionary.Add("Unit", $dynParam1)
#use the array
return $paramDictionary
} #if
} #dynamic parameter
Begin {
Write-Verbose "Starting $($myinvocation.MyCommand)"
#set a default size unit
if ($Detail -eq 'Size' -AND (-not $PSBoundParameters.ContainsKey("unit"))) {
$PSBoundParameters.Add("Unit", "bytes")
}
#these internal helper functions could be combined but I'll keep them
#separate for the sake of clarity.
function _getCount {
[cmdletbinding()]
Param([string]$Path)
(Get-ChildItem -Path $path -File).count
}
function _getSize {
[cmdletbinding()]
Param([string]$Path, [string]$Unit)
$sum = (Get-ChildItem $path -File | Measure-Object -Sum -Property length).sum
#write-verbose "detected $unit"
Switch ($Unit) {
"KB" { "$([math]::round($sum/1KB,2))KB" }
"MB" { "$([math]::round($sum/1MB,2))MB" }
"GB" { "$([math]::round($sum/1GB,2))GB" }
Default { $sum }
}
}
Write-Verbose "PSBoundParameters"
Write-Verbose ($PSBoundParameters | Out-String)
#build a hashtable of parameters to splat to Get-ChildItem
$gciParams = @{
Path = $Path
Directory = $True
}
if ($PSBoundParameters["Depth"]) {
$gciParams.Add("Depth", $PSBoundParameters["Depth"])
}
if ($PSBoundParameters["Recurse"]) {
$gciParams.Add("Recurse", $PSBoundParameters["Recurse"])
}
} #begin
Process {
Write-Verbose "Processing $(Convert-Path $Path)"
$directories = Get-ChildItem @gciParams
#this code could be consolidated using techniques like splatting. This version emphasizes clarity.
if ($Detail -eq "count") {
Write-Verbose "Getting file count"
if ($Recurse) {
$directories | Get-ChildItem -Recurse -Directory | Format-Wide -Property { "$($_.name) [$( _getCount $_.fullname)]" } -AutoSize -GroupBy @{Name = "Path"; Expression = { $_.Parent }}
}
else {
$directories | Format-Wide -Property { "$($_.name) [$( _getCount $_.fullname)]" } -AutoSize -GroupBy @{Name="Path";Expression={$_.Parent}}
}
}
else {
Write-Verbose "Getting file size in $($PSBoundParameters['Unit']) units"
if ($Recurse) {
$directories | Get-ChildItem -Recurse -Directory | Format-Wide -Property { "$($_.name) [$( _getsize -Path $_.fullname -unit $($PSBoundParameters['Unit']))]" } -AutoSize -GroupBy @{Name = "Path"; Expression = { $_.Parent }}
}
else {
$directories | Format-Wide -Property { "$($_.name) [$( _getsize -path $_.fullname -unit $($PSBoundParameters['Unit']))]" } -AutoSize -GroupBy @{Name = "Path"; Expression = { $_.Parent }}
}
}
} #Process
End {
Write-Verbose "Ending $($myinvocation.MyCommand)"
}
}
My function includes code to address some of the challenge's bonus elements. First, I'm using parameter validation to ensure that the path exists and it is a FileSystem path.
[Parameter(Position = 0,HelpMessage = "Enter a file system path")]
[ValidateScript({( Test-Path $_ ) -AND ((Get-Item $_).psprovider.name -eq "FileSystem")})]
[string]$Path = ".",
I started out with 2 ValidateScript settings, but decided to combine them. The Detail parameter is defined with a ValidateSet attribute.
[Parameter(Position = 1,HelpMessage = "What detail do you want to see? Size or Count of files?")]
[ValidateSet("Size", "Count")]
[string]$Detail = "Count",
Now for the fun part. I wanted a way to let the user format the total file size, i.e. in KB , if they wanted. But this only needs to happen if the user elects to use the Size value for the Detail parameter. I opted to use a Dynamic Parameter.
DynamicParam {
if ($Detail -eq "Size") {
#define a parameter attribute object
$attributes = New-Object System.Management.Automation.ParameterAttribute
$attributes.HelpMessage = "Enter a unit of measurement: KB, MB, GB Bytes."
#define a collection for attributes
$attributeCollection = New-Object -Type System.Collections.ObjectModel.Collection[System.Attribute]
$attributeCollection.Add($attributes)
#define an alias
$alias = New-Object System.Management.Automation.AliasAttribute -ArgumentList "as"
$attributeCollection.Add($alias)
#add a validateion set
$set = New-Object -type System.Management.Automation.ValidateSetAttribute -ArgumentList ("bytes", "KB", "MB", "GB")
$attributeCollection.add($set)
#define the dynamic param
$dynParam1 = New-Object -Type System.Management.Automation.RuntimeDefinedParameter("Unit", [string], $attributeCollection)
$dynParam1.Value = "Bytes"
#create array of dynamic parameters
$paramDictionary = New-Object -Type System.Management.Automation.RuntimeDefinedParameterDictionary
$paramDictionary.Add("Unit", $dynParam1)
#use the array
return $paramDictionary
} #if
} #dynamic parameter
This parameter only exists if the Detail parameter is "Size". The code creates a dynamic parameter as Unit with an alias of As. It also defines a validation set.
The main part of the function calls Get-ChildItem with the proper parameters and then formats the results using Format-Wide with auto sizing and grouping on the Parent path.
Here's the default behavior.
Or using the dynamic parameter.
This works, but there are a number of potential drawbacks.
First, the dynamic parameter is problematic. By default it doesn't show in command help.
Although PSReadline and tab-completion should detect it. I'm not a big fan of dynamic parameters, especially when there are other options. In this function, I could have defined multiple parameter sets. One for Count and one for Size. If I were to go forward with this function, that is a change I would make. I kept the dynamic parameter in this example because I get asked about it often and here's a nice working demonstration.
The other issue with this approach is that formatting is included in the function. That means the output of this function is a format wide directive, not a "real" object. They only thing I can do is look at the output or pipe it to Out-Printer or Out-File. You almost never want to to include Format commands in your function. That's one of the reasons I used Show as the command verb. I'm not getting something, I'm showing it.
Using a PowerShell Class
Since I'm writing a function, it needs to write an object to the pipeline. I can pipe the function output to Format-Wide to get the desired result. I could have used Select-Object or the [pscustomobject] type in my function but I opted for a PowerShell class definition.
Class DirectoryStat {
[string]$Name
[string]$Path
[int64]$FileCount
[int64]$FileSize
[string]$Parent
[string]$Computername = [System.Environment]::MachineName
} #close class definition
The class doesn't have any methods or special constructors. I then wrote a function to use this class.
Function Get-DirectoryInfo {
[cmdletbinding()]
[alias("dw")]
[OutputType("DirectoryStat")]
Param(
[Parameter(Position = 0)]
[ValidateScript( { (Test-Path $_ ) -AND ((Get-Item $_).psprovider.name -eq "FileSystem") })]
[string]$Path = ".",
[switch]$Recurse,
[int32]$Depth
)
Begin {
Write-Verbose "Starting $($myinvocation.MyCommand)"
#initialize a collection to hold the results
$data = [System.Collections.Generic.list[object]]::new()
function _newDirectoryStat {
[CmdletBinding()]
Param(
[Parameter(ValueFromPipelineByPropertyName, Mandatory)]
[string]$PSPath
)
Begin {}
Process {
$path = Convert-Path $PSPath
$name = Split-Path -Path $Path -Leaf
$stat = Get-ChildItem -Path $path -File | Measure-Object -Property Length -Sum
$ds = [DirectoryStat]::New()
$ds.Name = $name
$ds.Path = $Path
$ds.FileCount = $stat.Count
$ds.FileSize = $stat.Sum
$ds.Parent = (Get-Item -Path $path).Parent.FullName
$ds
}
end {}
}
Write-Verbose "PSBoundParameters"
Write-Verbose ($PSBoundParameters | Out-String)
#build a hashtable of parameters to splat to Get-ChildItem
$gciParams = @{
Path = $Path
Directory = $True
}
if ($PSBoundParameters["Depth"]) {
$gciParams.Add("Depth", $PSBoundParameters["Depth"])
}
if ($PSBoundParameters["Recurse"]) {
$gciParams.Add("Recurse", $PSBoundParameters["Recurse"])
}
} #begin
Process {
Write-Verbose "Processing $(Convert-Path $Path)"
$data.Add((Get-ChildItem @gciParams | _newDirectoryStat))
} #Process
End {
#pre-sort the data
$data | Sort-Object -Property Parent, Name
Write-Verbose "Ending $($myinvocation.MyCommand)"
}
}
As you can see, the function writes an object to the pipeline which I can format anyway I need.
The challenge was on displaying results in a wide format. As you can see, that is certainly possible. But to make this easier, since I'm using a custom object, I can create a custom format file. I used New-PSFormatXML from the PSScriptTools module to create a default view and then a second named view.
dw | New-PSFormatXML -Path .\directorystat.format.ps1xml -Properties Name -GroupBy Parent -FormatType Wide
dw | New-PSFormatXML -Path .\directorystat.format.ps1xml -Properties Name -GroupBy Parent -FormatType Wide -append -ViewName sizekb
My function has an alias of dw. I modified the file with scriptblocks so that the default view is a wide entry with a custom display showing the directory name and the file count in brackets. I also updated the sizeKB view to show the file size formatted in KB inside the square brackets. From this I simply copied, pasted and edited to get additional views.
All I need to do is load the file.
Update-FormatData .\directorystat.format.ps1xml
Get-FormatView is also part of the PSScriptTools module. With this format file, I now have a default view with file count values.
Or I can name additional wide views.
As you can see, I added ANSI-escape sequences in the ps1xml file. This should work in Windows PowerShell and PowerShell 7.
<?xml version="1.0" encoding="UTF-8"?>
<!--
format type data generated 10/07/2020 12:20:03 by PROSPERO\Jeff
File created with New-PSFormatXML from the PSScriptTools module
which can be installed from the PowerShell Gallery
-->
<Configuration>
<ViewDefinitions>
<View>
<!--Created 10/07/2020 12:20:03 by PROSPERO\Jeff-->
<Name>default</Name>
<ViewSelectedBy>
<TypeName>DirectoryStat</TypeName>
</ViewSelectedBy>
<GroupBy>
<ScriptBlock>"$([char]0x1b)[1;93m$($_.Parent)$([char]0x1b)[0m"</ScriptBlock>
<Label>Path</Label>
</GroupBy>
<WideControl>
<AutoSize />
<WideEntries>
<WideEntry>
<WideItem>
<ScriptBlock>"$([char]0x1b)[92m{0}$([char]0x1b)[0m [{1}]" -f $_.Name,$_.filecount</ScriptBlock>
</WideItem>
</WideEntry>
</WideEntries>
</WideControl>
</View>
<View>
<!--Created 10/07/2020 12:23:20 by PROSPERO\Jeff-->
<Name>size</Name>
<ViewSelectedBy>
<TypeName>DirectoryStat</TypeName>
</ViewSelectedBy>
<GroupBy>
<ScriptBlock>"$([char]0x1b)[1;93m$($_.Parent)$([char]0x1b)[0m"</ScriptBlock>
<Label>Path</Label>
</GroupBy>
<WideControl>
<AutoSize />
<WideEntries>
<WideEntry>
<WideItem>
<ScriptBlock>"$([char]0x1b)[92m{0}$([char]0x1b)[0m [$([char]0x1b)[38;5;190m{1}$([char]0x1b)[0m]" -f $_.Name,$_.filesize</ScriptBlock>
</WideItem>
</WideEntry>
</WideEntries>
</WideControl>
</View>
<View>
<!--Created 10/07/2020 12:23:20 by PROSPERO\Jeff-->
<Name>sizekb</Name>
<ViewSelectedBy>
<TypeName>DirectoryStat</TypeName>
</ViewSelectedBy>
<GroupBy>
<ScriptBlock>"$([char]0x1b)[1;93m$($_.Parent)$([char]0x1b)[0m"</ScriptBlock>
<Label>Path</Label>
</GroupBy>
<WideControl>
<WideEntries>
<WideEntry>
<WideItem>
<ScriptBlock>"$([char]0x1b)[92m{0}$([char]0x1b)[0m [$([char]0x1b)[38;5;164m{1}KB$([char]0x1b)[0m]" -f $_.Name,([math]::Round($_.filesize/1KB,2))</ScriptBlock>
</WideItem>
</WideEntry>
</WideEntries>
</WideControl>
</View>
<View>
<!--Created 10/07/2020 12:23:20 by PROSPERO\Jeff-->
<Name>sizemb</Name>
<ViewSelectedBy>
<TypeName>DirectoryStat</TypeName>
</ViewSelectedBy>
<GroupBy>
<ScriptBlock>"$([char]0x1b)[1;93m$($_.Parent)$([char]0x1b)[0m"</ScriptBlock>
<Label>Path</Label>
</GroupBy>
<WideControl>
<WideEntries>
<WideEntry>
<WideItem>
<ScriptBlock>"$([char]0x1b)[92m{0}$([char]0x1b)[0m [$([char]0x1b)[38;5;147m{1}MB$([char]0x1b)[0m]" -f $_.Name,([math]::Round($_.filesize/1mb,2))</ScriptBlock>
</WideItem>
</WideEntry>
</WideEntries>
</WideControl>
</View>
</ViewDefinitions>
</Configuration>
This is the approach I recommend when it comes to scripting with PowerShell. Have your functions write objects to the pipeline. This is your raw output. Then use the PowerShell commands to manipulate and format the data as necessary. If you know you want some default formatting, then create a format.ps1xml file.
I realize some of you are still beginning to learn PowerShell and that's ok. That's the point behind the Iron Scripter challenges -- to test and extend your skills. I hope you'll give some of the other challenges a spin.
2 thoughts on “Open Up Wide with PowerShell”
Comments are closed.