Skip to content

Instantly share code, notes, and snippets.

@JustinGrote
Last active June 7, 2024 18:41
Show Gist options
  • Save JustinGrote/8dcf827517021ac0210b65db7bceecf3 to your computer and use it in GitHub Desktop.
Save JustinGrote/8dcf827517021ac0210b65db7bceecf3 to your computer and use it in GitHub Desktop.
A proxy for Format-Table to apply the resultant view or save it as a format definition

EasyFormat

This module provides an improved Format-Table that lets you persist the resultant Format-Table view either to the current session or to a .ps1xml formatting file.

This module requires PowerShell 7.2+, however the generated XML format files can be used with Windows PowerShell.

How it Works

This module attaches additional metadata to format table objects, and utilizes the same internal PowerShell methods and types to both model the formatting and serialize it to XML. The "live apply" injects the formatting directly into the InitialSessionState Formats collection.

Impetus

This module was inspired by @StartAutomating's EzOut Module, which provided a PowerShell interface to authoring XML formatting files. I wanted to take the concept and expand it to enable you to immediately persist changes, as well as "author" views using command syntax you are already familiar with such as format-table.

using namespace System.Management.Automation
using namespace System.Management.Automation.Runspaces
using namespace System.Collections
using namespace System.Collections.Generic
using namespace System.Text
using namespace System.Xml
using namespace System.IO
$ErrorActionPreference = 'Stop'
$SCRIPT:RunspaceFormats = [Runspace]::DefaultRunspace.InitialSessionState.Formats
function ConvertFrom-Format {
<#
.SYNOPSIS
Converts Format output to a FormatViewDefinition that can be persisted to the session state or saved as an XML file with ConvertTo-FormatXml
#>
param(
#The name of the format view definition assigned to the format data. Default is 'CustomEasyTableView
[ValidateNotNullOrEmpty()][string]$Name = 'CustomEasyTableView',
#The format data to convert. It's best to pipe to this command from Format-Table, etc. for best results
[Parameter(ValueFromPipeline)][object]$InputObject
)
begin {
$ErrorActionPreference = 'Stop'
New-Variable -Name 'FormatStartData'
}
process {
#HACK: This is an internal type so we cannot use -is or reference the type directly
if (($InputObject.GetType().Name) -ne 'FormatStartData') {
return
}
if ($FormatStartData) {
Write-Warning 'Multiple formats detected, please pass only one format type to this function. It will only process the first format detected'
return
}
$formatStartData = $InputObject
}
end {
if (-not $formatStartData) {
Write-Error 'You must provide formatting info to this command. Try | Format-Table | ConvertFrom-Format'
}
$shapeInfo = $formatStartData.shapeInfo
if ($shapeInfo.HeaderInfo.count -gt 1) { throw 'Multiple Header Infos detected. This is a bug and should never happen.' }
[PSControl]$control = switch ($shapeInfo.GetType().Name) {
'TableHeaderInfo' {
$tcProps = @{
CalculatedProperties = $formatStartData.CalculatedProperties
AutoSize = $null -ne $FormatStartData.autoSizeInfo
Wrap = $formatStartData.Wrap
}
New-TableControl @tcProps $shapeInfo
}
default { throw [NotImplementedException]'This type of format is not supported yet' }
}
return [FormatViewDefinition]::new($Name, $control)
}
}
filter Add-Format {
<#
.SYNOPSIS
Adds a new format view definition to the current session state
#>
[CmdletBinding()]
param(
#Supply the type to associate to the format definition
[Parameter(Mandatory)][string]$Type,
[Parameter(Mandatory, ValueFromPipeline)]
[Management.Automation.FormatViewDefinition]$FormatViewDefinition,
#Pass through the new Extended Type Definition
[switch]$PassThru,
#Don't save the format data to the session state as the default view for the object
[switch]$NoPersist,
#Update the definitions but do not process them. You should rarely need to do this, maybe if adding a lot of type definitions at runtime amd then update them separately
[switch]$NoUpdate
)
$ErrorActionPreference = 'Stop'
$formatName = $FormatViewDefinition.Name
#TODO: More Advanced Conflict/Update Checking
$existingTypeData = Get-FormatData -TypeName $Type
| Where-Object TypeNames -Contains $Type
$existingFormatData = $existingTypeData
| ForEach-Object FormatViewDefinition
| Where-Object Name -EQ $formatName
| Where-Object Control -Is [TableControl]
$formatData = [ExtendedTypeDefinition]::new(
$Type,
[List[FormatViewDefinition]]@($FormatViewDefinition)
)
if (-not $NoPersist) {
if ($existingFormatData) {
#Update the formatting information for the type
throw [NotImplementedException]"#TODO: An existing table view $formatName for $Type was found. Updating existing format data is not yet implemented."
# Write-Verbose "Found existing table format data $Name for type $Type. Updating..."
# $existingFormatData.Control = $FormatViewDefinition.Control
}
Write-Verbose "Adding new format definition $formatName for type $Type."
if ($existingTypeData) {
#HACK: We need to "prepend" our new type data to have it take precedence and be the default view, but unfortunately
#the collection has no prepend method, so we need to create a new list, prepend, flush the formats, and add the
#new list with the format prepended
Write-Verbose "Existing type data found for $Type. Prepending new format data."
[List[SessionStateFormatEntry]]$newFormats = $SCRIPT:RunspaceFormats
$newFormats.insert(0, $formatData)
$SCRIPT:RunspaceFormats.Clear()
$SCRIPT:RunspaceFormats.Add($newFormats)
} else {
#If it is a net-new type format, then prepending isn't needed
$SCRIPT:RunspaceFormats.Add($formatData)
#TODO: -Append parameter to add to an existing type definition
}
}
if (-not $NoUpdate) {
Update-FormatData
}
if ($PassThru) {
return $formatData
}
}
function New-TableControl {
[OutputType([TableControl])]
param($TableHeaderInfo, $CalculatedProperties, [bool]$AutoSize, [bool]$Wrap) #Should be TableHeaderInfo
[TableControl]$table = [TableControl]::new()
[TableControlRow]$rowFormat = [TableControlRow]::new()
$rowFormat.Wrap = $Wrap
#HACK: There is an additional objectCount property that is apparently not used in the translation
$table.AutoSize = $AutoSize
$table.Rows = $rowFormat
$table.HideTableHeaders = $TableHeaderInfo.hideHeader
#FIXME: This is set in format data but doesn't take effect in the view. Need to find out why.
if ($table.HideTableHeaders) {
Write-Warning 'BUG: HideTableHeaders will only work if you save this format definition to XML. More Info: https://github.com/PowerShell/PowerShell/issues/23841'
}
if ($TableHeaderInfo.tableColumnInfoList.count -le 0) { throw 'No table column info found' }
foreach ($columnInfo in $TableHeaderInfo.tableColumnInfoList) {
#This is a little confusing, but the row columns and the headers should almost always align.
#The display label is defined on the header and the actual property it references is defined in the column.
#If the property and label are the same, the label can be omitted
#So for each property in the HeaderInfo, we create a new TableControlColumnHeader and TableControlColumn
#If the columnInfo has a calculated property with a scriptblock, the propertyname needs to be set to the header label
#to display correctly. Otherwise, label can be omitted unless it is different from the propertyname.
[string]$scriptBlock = $null -ne $CalculatedProperties ?
$CalculatedProperties[$columnInfo.propertyName] :
$null
[string]$label = $scriptBlock ? $columnInfo.propertyName : $columnInfo.Label
$columnHeader = [TableControlColumnHeader]::new($label, $columnInfo.Width, $columnInfo.Alignment)
$table.Headers.Add($columnHeader)
if ($columnInfo.propertyName) {
$displayEntry = $scriptBlock ?
[DisplayEntry]::new($scriptBlock.Trim(), 'ScriptBlock') :
[DisplayEntry]::new($columnInfo.propertyName, 'Property')
$column = [TableControlColumn]::new($columnInfo.Alignment, $displayEntry)
$rowFormat.Columns.Add($column)
}
}
return $table
}
function ConvertTo-FormatXml {
<#
.SYNOPSIS
Converts ExtendedTypeDefinition objects to XML format data
#>
[CmdletBinding(DefaultParameterSetName = 'FormatView')]
param(
[Parameter(ParameterSetName = 'TypeDefinition', Mandatory, ValueFromPipeline)]
[Management.Automation.ExtendedTypeDefinition[]]$TypeDefinition,
[Parameter(ParameterSetName = 'FormatView', Mandatory, ValueFromPipeline)]
[Management.Automation.FormatViewDefinition]$FormatDefinition,
[Parameter(ParameterSetName = 'FormatView', Mandatory, ValueFromPipeline)]
[String]$TypeName,
[switch]$Compress,
[string]$OutFile
)
begin {
Write-Host -ForegroundColor Magenta "ParameterSetName: $($PSCmdlet.ParameterSetName)"
[List[ExtendedTypeDefinition]]$TypeDefinitions = @()
}
process {
if ($FormatDefinition) {
[List[FormatViewDefinition]]$FormatDefinitionList = @($FormatDefinition)
$TypeDefinitions.Add(([ExtendedTypeDefinition]::new($TypeName, $FormatDefinitionList)))
}
foreach ($tdItem in $TypeDefinition) {
$TypeDefinitions.Add($tdItem)
}
}
end {
#HACK: Fast way to get SMA rather than enumerating. There's no guarantee FormatXML will remain here but it is reasonably safe.
$smaAssembly = [psobject].assembly
$writeToXml = $smaAssembly.
GetType('Microsoft.PowerShell.Commands.FormatXmlWriter').
GetMethod('WriteToXml', @('Static', 'NonPublic'))
$xmlSettings = [XmlWriterSettings]::new()
if (-not $Compress) {
$xmlSettings.Indent = $true
$xmlSettings.NewLineOnAttributes = $true
}
$sb = [StringBuilder]::new()
$xmlWriter = [XmlWriter]::Create($sb, $xmlSettings)
$writeToXmlParams = @(
$xmlWriter,
$TypeDefinitions,
$true #Export Script blocks
)
$writeToXml.Invoke($null, $writeToXmlParams)
$xmlWriter.Flush()
$xmlOutput = $sb.ToString()
if ($OutFile) {
Out-File -InputObject $xmlOutput -FilePath $OutFile
return
}
return $xmlOutput
}
}
function Format-EasyTable {
[CmdletBinding(DefaultParameterSetName = 'Property')]
param(
[Parameter(ParameterSetName = 'Property', Position = 0)]
[System.Object[]]
${Property},
#TODO: Implement these properties
# [System.Object]
# ${GroupBy},
[Parameter(ParameterSetName = 'View')]
[string]
${View},
# [switch]
# ${ShowError},
# [switch]
# ${DisplayError},
# [switch]
# ${Force},
# [ValidateSet('CoreOnly', 'EnumOnly', 'Both')]
# [string]
# ${Expand},
[switch]
${AutoSize},
# [switch]
# ${RepeatHeader},
[switch]
${HideTableHeaders},
[switch]
${Wrap},
#region Custom Properties
#If specified, will not save the format data to the session state as the default view for the object
[Parameter(ParameterSetName = 'Property')]
[switch]$NoPersist,
#If specified, will save the view with a custom name that can be referenced later with Format-Table -View
[Parameter(ParameterSetName = 'Property')]
[string]$ViewName,
#endregion Custom Properties
#InputObject goes as the last property as this is usually provided via the pipeline and we don't want it to be positional.
[Parameter(Mandatory, ValueFromPipeline = $true)]
[psobject]
${InputObject}
)
begin {
[List[Object]]$InputObjects = @()
#A deduplicated list of the types provided to format-table, for purposes of collecting
[HashSet[String]]$TypeNames = @()
}
process {
#Collect the input objects on the pipeline, if any
$InputObjects.Add($PSItem)
}
end {
#Replace the boundparameters for splatting to Format-Table
if ($InputObjects.count -gt 0) {
$PSBoundParameters.InputObject = $InputObjects
}
#Collect the types of the input objects for attaching to the formatStartData.
foreach ($inputObjectItem in $PSBoundParameters.InputObject) {
[void]$TypeNames.Add($inputObjectItem.GetType())
}
#TODO: Gather calculated property information to attach as metadata
#Remove Extension Properties
foreach ($customParam in 'NoPersist', 'ViewName') {
[void]$PSBoundParameters.Remove($customParam)
}
$ftResult = Microsoft.PowerShell.Utility\Format-Table @PSBoundParameters -ErrorAction Stop
#If -View was used, we don't save the output and return immediately.
if ($View) {
return $ftResult
}
if ($ftResult.count -eq 0) {
Write-Warning 'No formatting output found. Did you pass any objects?'
return
}
if ($ftResult[0].GetType().Name -ne 'FormatStartData' ) {
throw [InvalidDataException]'Unexpected output type from Format-Table.'
}
#Add the types as an ETS property to FormatStartData
Add-Member -InputObject $ftResult[0] -NotePropertyName 'TypeNames' -NotePropertyValue $TypeNames -Force
Add-Member -InputObject $ftResult[0] -NotePropertyName 'Wrap' -NotePropertyValue $Wrap -Force
$calcPropertyMetadata = Get-CalculatedPropertiesMetadata $PSBoundParameters.Property
if ($calcPropertyMetadata.count -gt 0) {
Add-Member -InputObject $ftResult[0] -NotePropertyName 'CalculatedProperties' -NotePropertyValue $calcPropertyMetadata -Force
}
$convertFromFormatParams = @{}
if ($ViewName) {
$convertFromFormatParams.Name = $ViewName
}
foreach ($Type in $ftResult[0].TypeNames) {
$ftResult
| ConvertFrom-Format @convertFromFormatParams
| Add-Format -Type $Type -NoPersist:$NoPersist
}
return $ftResult
}
}
#region Private
function Get-CalculatedPropertiesMetadata ([object]$Properties) {
#TODO: In order to do this we have to update the typedata.
$calcProps = @{}
foreach ($Property in $Properties) {
if ($Property -is [scriptblock]) {
throw [NotSupportedException]'Raw Scriptblocks in -Properties are not supported, use hashtables instead'
}
if ($Property -is [string]) {
#This has already been handled by Format-Table, there's nothing to include
continue
}
#Only hashtables from this point on
if ($Property -isnot [hashtable]) {
Write-Warning "$($Property.GetType()) is not currently implemented for parsing in -Property and will be ignored"
continue
}
if ($Property.ContainsKey('Name') -and $Property.ContainsKey('N')) {
throw [InvalidDataException]'Cannot specify both Name and N for a calculated Property'
}
if ($Property.ContainsKey('Expression') -and $Property.ContainsKey('E')) {
throw [InvalidDataException]'Cannot specify both Expression and E for a calculated Property'
}
[string]$Name = $null
foreach ($key in $Property.keys) {
#TODO: Add FormatString, Width, Alignment if these properties not present in FT
switch -Wildcard ($key) {
'Name' {
$Name = $Property[$key]
break
}
'N' {
$Name = $Property[$key]
break
}
'default' {
Write-Warning "$key is not a currently implemented key for a calculated property in -Properties and will be ignored"
continue
}
}
}
if (-not $Name) {
Write-Warning 'The Name key was not included in the calculated property hashtable. That property will be ignored.'
continue
}
if (-not ($Property.Expression -or $Property.E)) {
Write-Warning "Calculated Property $Name does not have an Expression key. This property will be ignored."
continue
}
$calcProps[$Name] = $Property.Expression ?? $Property.E
}
return $calcProps
}
#endregion Private
Describe 'Format-EasyTable' {
BeforeAll {
Import-Module $PSScriptRoot/EasyFormat.psm1 -Force
}
Context 'Metadata' {
It 'Adds Type Metadata' {
$actual = Get-ChildItem -File $PSScriptRoot/EasyFormat.psm1
| Format-EasyTable -NoPersist
$actual[0].TypeNames | Should -Contain 'System.IO.FileInfo'
}
It 'Adds Property Metadata (<Name>)' {
$actual = Get-ChildItem -File $PSScriptRoot/EasyFormat.psm1
| Format-EasyTable -NoPersist -Property $Property, Length
$actual[0].CalculatedProperties.Count | Should -HaveCount 1
$actual[0].CalculatedProperties['NamePlusBang'] | Should -BeOfType [ScriptBlock]
$actual[0].CalculatedProperties['NamePlusBang'].Ast.ToString() | Should -Be { $_.Name + '!' }.Ast.ToString()
} -TestCases @{
Name = 'N/E'
Property = @{
N = 'NamePlusBang'
E = { $_.Name + '!' }
}
},
@{
Name = 'Name/E'
Property = @{
Name = 'NamePlusBang'
E = { $_.Name + '!' }
}
},
@{
Name = 'N/Expression'
Property = @{
N = 'NamePlusBang'
Expression = { $_.Name + '!' }
}
},
@{
Name = 'Name/Expression'
Property = @{
Name = 'NamePlusBang'
Expression = { $_.Name + '!' }
}
}
}
Context 'New Type' {
BeforeAll {
class ___EasyFormatTestType {
[string] $ShownProperty
[string] $HiddenProperty
[string] $CustomViewProperty
}
$SCRIPT:testType = [___EasyFormatTestType]@{
ShownProperty = 'Shown'
HiddenProperty = 'Hidden'
}
}
AfterEach {
RemoveFormatType '___EasyFormatTestType'
}
It 'Specific Properties' {
$testType
| Format-EasyTable ShownProperty
# Check that the default formatting looks as expected.
($testType | Format-Table)[0].shapeInfo.tableColumnInfoList.label | Should -Be 'ShownProperty'
}
It 'Custom View Name' {
$testType
| Format-EasyTable ShownProperty, CustomViewProperty -ViewName PesterCustom
#Check that the custom format was loaded
([Runspace]::DefaultRunspace.InitialSessionState.Formats | Where-Object FormatData -Match 'EasyFormatTestType').FormatData.FormatViewDefinition.Name
# Check that the default formatting looks as expected when used with -View
($testType | Format-Table -View PesterCustom)[0].shapeInfo.tableColumnInfoList.label | Should -Be 'ShownProperty', 'CustomViewProperty'
($testType | Format-EasyTable -View PesterCustom)[0].shapeInfo.tableColumnInfoList.label | Should -Be 'ShownProperty', 'CustomViewProperty'
}
It 'Calculated Property' {
$testType
| Format-EasyTable ShownProperty, @{ N = 'HiddenPlusBang'; E = { $_.HiddenProperty + '!' } }
# Check that the default formatting looks as expected.
($testType | Format-Table)[0].shapeInfo.tableColumnInfoList.label | Should -Be 'ShownProperty', 'HiddenPlusBang'
}
It 'HideTableHeaders' {
$testType
| Format-EasyTable ShownProperty -HideTableHeaders -ViewName 'PesterTableHeaderTest'
# Check that the default formatting looks as expected.
$RunspaceFormats[-1].FormatData.FormatViewDefinition[0].Name | Should -Be 'PesterTableHeaderTest'
$RunspaceFormats[-1].FormatData.FormatViewDefinition[0].Control.HideTableHeaders | Should -BeTrue
}
It 'AutoSize' {
$testType
| Format-EasyTable -AutoSize ShownProperty -ViewName 'PesterTableAutosizeTest'
# Check that the default formatting looks as expected.
$RunspaceFormats[-1].FormatData.FormatViewDefinition[0].Name | Should -Be 'PesterTableAutosizeTest'
$RunspaceFormats[-1].FormatData.FormatViewDefinition[0].Control.AutoSize | Should -BeTrue
}
It 'Wrap' {
$testType
| Format-EasyTable -Wrap ShownProperty -ViewName 'PesterTableWrapTest'
# Check that the default formatting looks as expected.
$RunspaceFormats[-1].FormatData.FormatViewDefinition[0].Name | Should -Be 'PesterTableWrapTest'
$RunspaceFormats[-1].FormatData.FormatViewDefinition[0].Control.Rows.Wrap | Should -BeTrue
}
It 'NoPersist' {
$testType
| Format-EasyTable ShownProperty -NoPersist -ViewName 'PesterNoPersistTest'
# Check that the default formatting looks as expected.
$RunspaceFormats[-1].FormatData | Should -BeNullOrEmpty
}
}
Context 'Existing Type' {
AfterEach {
#Cleanup
if ('System.IO.FileInfo' -eq $RunspaceFormats[0].FormatData) {
$RunspaceFormats.RemoveItem(0)
Update-FormatData
}
}
It 'Prepends on Existing Type' {
(Get-Item $PSScriptRoot/EasyFormat.tests.ps1)
| Format-EasyTable Name, Length
$RunspaceFormats[0].FormatData.TypeName | Should -Be 'System.IO.FileInfo'
$RunspaceFormats[0].FormatData.FormatViewDefinition[0].Name | Should -Be 'CustomEasyTableView'
}
}
}
#Utility Functions
BeforeAll {
$SCRIPT:RunspaceFormats = [Runspace]::DefaultRunspace.InitialSessionState.Formats
function RemoveFormatType ($Type) {
#Cleanup the format data
$formats = [Runspace]::DefaultRunspace.InitialSessionState.Formats
$i = -1
$formats
| Where-Object {
$i++
$_.FormatData -Match $Type
}
| Select-Object -First 1
| Out-Null
$formats.RemoveItem($i)
Update-FormatData
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment