- The Operation Validation Framework: Test your infrastructure using Pester - Mon, Jun 25 2018
- How to write an Azure Function in PowerShell - Tue, Jun 12 2018
- Process file paths from the pipeline in PowerShell functions - Mon, Jun 4 2018
Suppose we want to write a function that counts the number of lines in a text file, and we want to pipe those files to our function. Cmdlets like Get-Item, Get-ChildItem, and Get-Content all accept input from the pipeline, but how do we write a function that behaves similarly to the core cmdlets? Let's see how we would go about creating our own function that supports this workflow.
Example directory
The directory below has some example files that we can test with. Notice that one of the files is named [file].txt using brackets []. This is a valid file name but illustrates the problem we must solve.
Using Get-Item and the -Path parameter does not return a result when attempting to get this file, but using the ‑LiteralPath parameter does.
Basic function
We'll start off with the basic function below that returns the number of lines in a file.
function Get-Lines { param( [string]$Path ) $content = Get-Content -Path $Path $content.Count }
Testing this function shows that it behaves as expected when passing in a file name via the -Path parameter.
As you can see, we run into trouble when trying to pass that file via the pipeline.
Improved function
We can make some improvements to this function so it accepts input from the pipeline. The improved example below shows a more complete function that works with accepting input in all types of scenarios.
function Get-Lines { <# .SYNOPSIS Counts the number of lines in a file. #> [cmdletbinding(DefaultParameterSetName = 'Path')] param( [parameter( Mandatory, ParameterSetName = 'Path', Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName )] [ValidateNotNullOrEmpty()] [SupportsWildcards()] [string[]]$Path, [parameter( Mandatory, ParameterSetName = 'LiteralPath', Position = 0, ValueFromPipelineByPropertyName )] [ValidateNotNullOrEmpty()] [Alias('PSPath')] [string[]]$LiteralPath ) process { # Resolve path(s) if ($PSCmdlet.ParameterSetName -eq 'Path') { $resolvedPaths = Resolve-Path -Path $Path | Select-Object -ExpandProperty Path } elseif ($PSCmdlet.ParameterSetName -eq 'LiteralPath') { $resolvedPaths = Resolve-Path -LiteralPath $LiteralPath | Select-Object -ExpandProperty Path } # Process each item in resolved paths foreach ($item in $resolvedPaths) { $fileItem = Get-Item -LiteralPath $item $content = $fileItem | Get-Content [pscustomobject]@{ Path = $fileItem.Name Lines = $content.Count } } } }
Parameters
This new function has quite a bit going on so let's break it down. To start, we have two parameters here called Path and LiteralPath in two separate parameter sets, meaning we can only use parameters belonging to a particular set at a time.
[cmdletbinding(DefaultParameterSetName = 'Path')] param( [parameter( Mandatory, ParameterSetName = 'Path', Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName )] [ValidateNotNullOrEmpty()] [SupportsWildcards()] [string[]]$Path, [parameter( Mandatory, ParameterSetName = 'LiteralPath', Position = 0, ValueFromPipelineByPropertyName )] [ValidateNotNullOrEmpty()] [Alias('PSPath')] [string[]]$LiteralPath )
LiteralPath is there so we can have support for file paths that contain special characters usually interpreted as wildcards. Both of these parameters accept a collection of strings, are mandatory, and are at position 0. The Path parameter accepts values from the pipeline, and you can see that both parameters accept values from the pipeline by property name.
We've also noted wildcard support on the Path parameter via the [SupportsWildcards()] attribute. This attribute is purely cosmetic. PowerShell's help system uses it to tell the user that the parameter accepts wildcards. We are also not accepting null or empty strings for either of these parameters via the [ValidateNotNullOrEmpty()] attribute.
Process Block
Now that we have our parameters defined, let's look at the process block of the function.
process { # Resolve path(s) if ($PSCmdlet.ParameterSetName -eq 'Path') { $resolvedPaths = Resolve-Path -Path $Path | Select-Object -ExpandProperty Path } elseif ($PSCmdlet.ParameterSetName -eq 'LiteralPath') { $resolvedPaths = Resolve-Path -LiteralPath $LiteralPath | Select-Object -ExpandProperty Path } # Process each item in resolved paths foreach ($item in $resolvedPaths) { $fileItem = Get-Item -LiteralPath $item $content = $fileItem | Get-Content [pscustomobject]@{ Path = $fileItem.Name Lines = $content.Count } } }
We have our code defined in the process block because it will run for every item it receives from the pipeline. Inside the process block, the first thing we need to do is resolve the paths passed in.
We check the parameter set name used because depending on whether we received a Path or LiteralPath parameter, we need to resolve those paths differently using Resolve-Path (which interprets the wildcard characters). Once we have an array of resolved paths for the current item in the pipeline, we need to loop over them because each item sent down the pipeline could be an array of paths.
Now we finally get to the point of the function, to count the number of lines in a file. We'll first get the file with Get-Item and make sure to use the -LiteralPath parameter to ensure we can also get file names that may include characters normally considered wildcards. Once we have the file, we'll pipe it to Get-Content. We'll then create a custom object that includes the name of the file as well as the number of lines.
Using the improved function
This improved function handles pipeline input much better. It works correctly with System.IO.FileInfo objects returned from Get-ChildItem and Get-Item as well as normal strings. It even supports literal paths.
Subscribe to 4sysops newsletter!
Conclusion
Adding proper support for files sent via the pipeline involves a fair amount of code, but it really does make the function bulletproof. It's worth the effort to provide the best user experience possible.
How does the script tell the difference between literal path names coming from get-childitems and from a wildcard string. eg gci | get-lines vs "*.txt" | get-lines. How does it know to use -literalpath for gci and -path for "*.txt" ?