1

For reasons I'm starting to regret, I got into the habit of just working with [System.IO.FileInfo] and now I'm wondering if there is a best practice around avoiding that and just using full names to the file(s) or if there is a different workaround for my current conundrum.

I need to make a lot of my smaller scripts work with powershell.exe vs. pwsh.exe because they're going to be used by folks and computers that don't have PowerShell (Core) installed - but every once in a while there arises an issue. This time it is the handling of whatever is returned from Get-ChildItem and the fact that Windows PowerShell doesn't give you the full path like PowerShell (Core) does. One workaround I have would be to force the full name ($file.FullName), but that in turn breaks the fact that I'm accustomed to working with System.IO.FileInfo variables.

So first question without examples: What is the best practice? Should I have been using System.IO.FileInfo in the first place?

Second question, with examples: Is there a better way to handle this so that Windows PowerShell and PowerShell (Core) act consistently?

Consider the following - at this point I would probably call a function to act on each qualifying input file (using filtering on name or file extension, etc. to get the right set).

PS C:\tmp> Function CustomFunction{
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[System.IO.FileInfo]$inputFile
)
$inputFile.BaseName
$inputFile.DirectoryName
$inputFile.GetType()
"`n`n"
}
PS C:\tmp> (Get-ChildItem -LiteralPath $PWD -File).ForEach({CustomFunction $_})

In PowerShell (Core) - The type System.IO.FileSystemInfo and the function would work even if the file itself isn't located in the working directory

Another File
C:\tmp

IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     False    FileInfo                                 System.IO.FileSystemInfo

Windows PowerShell is still using a type of System.IO.FileSystemInfo - but there is definitely something different between them. I'm not sure what "IsSerial" actually checks, but if CustomFunction were taking action on the files then it won't work if they're not in the working directory.

Another File
C:\tmp

IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     FileInfo                                 System.IO.FileSystemInfo

So - what's my best course of action? I like the FileInfo objects themselves because they have some handy properties like BaseName, Directory, DirectoryName, and Extension. There are probably also a number of useful methods available that I might be using in my functions.

If I just essentially pass $_.FullName to the function, then within the function it is a string and I'll need to use Split-Path among other things to get similar properties that I'm working with

immobile2
  • 489
  • 2
  • 15

2 Answers2

2

Yes, the inconsistent stringification of [System.IO.FileInfo] instances in Windows PowerShell is unfortunate - sometimes by file name only, other times by full path (everything in this answer applies analogously to System.IO.DirectoryInfo instances) - this has since been fixed in PowerShell (Core) 7+ (via an underlying .NET API change), where such instances now consistently stringify to their full path.

To make code work robustly in both PowerShell editions, two fundamental scenarios apply:

  • Passing [System.IO.FileInfo] instances to other file-processing cmdlets, in which case you can provide them as pipeline input - see the next section.

  • Passing them to external programs and .NET methods, in which case their .FullName property must be used - see the bottom section.


When you need to pass Get-ChildItem / Get-Item to other file-processing cmdlets, namely cmdlets that have pipeline-binding -LiteralPath parameters, such as Get-Item and Get-ChildItem you can stick with [System.IO.FileInfo] instances and provide them as pipeline input ; e.g.:

# Robust alternative to the following *brittle* commands:
#    Get-ChildItem $someFileInfoInstance  # -Path implied
#    Get-ChildItem -LiteralPath $someFileInfoInstance
# Same as: 
#    Get-ChildItem -LiteralPath $someFileInfoInstance.PSPath
$someFileInfoInstance | Get-ChildItem

A System.IO.FileInfo bound this way - assuming that it wasn't constructed directly - binds via its PowerShell provider-supplied .PSPath property value, which is always its full path, even in Windows PowerShell.
The reason that it is the .PSPath property value that binds is that -PSPath is defined as an alias of the -LiteralPath parameter, combined with declaring the latter as ValueFromPipelineByPropertyName.

Note:

  • Generally, all standard cmdlets that have a -LiteralPath parameter support this mechanism.

    • See the bottom section of this answer for how to infer whether the mechanism is supported by a given cmdlet by examining its parameter definitions.
  • An unfortunate exception due to its buggy implementation in Windows PowerShell is Import-Csv - again, this has since been fixed in PowerShell (Core) 7+.

  • As an aside: The problem generally wouldn't exist if passing a pipeline-binding parameter's value as an argument exhibited the same behavior as via the pipeline - see GitHub issue #6057.

    • That is, you would expect Get-ChildItem -LiteralPath $someFileInfoInstance to bind the parameter, which is string-typed, in the same way as $someFileInfoInstance | Get-ChildItem.
    • However, that is not the case: when passed as an argument, simple .ToString() stringification is performed, which in combination with Windows PowerShell's inconsistent stringification of System.IO.FileInfo instances causes situational malfunctioning.

Alternatively, you can always explicitly use the .PSPath property value to predictably get a full path, but that is both more verbose and error-prone (easy to forget).

Get-ChildItem -LiteralPath $someFileInfoInstance.PSPath

Note that .PSPath values are prefixed by the name of the PowerShell provider underlying the item at hand, such as Microsoft.PowerShell.Core\FileSystem:: for file-system items.

As such, .PSPath isn't suitable for passing for passing file-system paths to external programs or .NET methods, which are discussed next.


Passing file-system paths to external programs and .NET methods:

In these cases, a full, file-system-native path must be passed.

  • External programs and .NET methods are unaware of PowerShell-only drives (those established with New-PSDrive), so any paths based on them wouldn't be recognized (and neither would PowerShell paths referring to non-file-system items, such as registry keys based on PowerShell's HKLM: drive).

  • In the case of external programs, a relative file-system path would work too, but .NET methods require a full path, due to .NET's working directory usually differing from PowerShell's (see this answer).

To ensure use of a full, file-system-native path:

  • For [System.IO.FileInfo] / [System.IO.DirectoryInfo] instances, use their .FullName property.

    • As stated above, this is no longer necessary in PowerShell (Core) v6.1+, where such instances consistently stringify to their .FullName property values. (Stringification happens invariably when passing arguments to external programs, and when passing arguments to string-typed (file-path) parameters of .NET methods).
  • For [System.Management.Automation.PathInfo] instances, such as returned by Get-Location and reflected in the automatic $PWD variable variable, use their .ProviderPath property.

Examples:

cmd /c echo (Get-Item .).FullName
cmd /c echo $PWD.ProviderPath
mklement0
  • 382,024
  • 64
  • 607
  • 775
1

There was a lengthy discussion about this when the change was made to default ToString to result in a full path instead of a relative path.

TL;DR

Steve Lee stated the best practice is to explicitly declare whether you want the full path or the relative path by using $_.Name or $_.Fullname.

Bad Practice ❌

$fileInfo = Get-ChildItem $MyFilePath
Write-Host "My file is: $fileInfo"

Best Practice ✅

$fileInfo = Get-ChildItem $MyFilePath
Write-Host "My file is: $($fileInfo.FullName)"

Details

Get-ChildItem returns a System.IO.FileInfo object in both Windows PowerShell and PowerShell Core. The problem you're encountering is with the implementation of the ToString method of the System.IO.FileInfo object.

You type and run this:

$fileInfo = Get-ChildItem $MyFilePath
Write-Host "My file is: $fileInfo"

...which gets translated into this:

$fileInfo = Get-ChildItem $MyFilePath
Write-Host "My file is: $(fileInfo.ToString())"

..which gets translated to this on Windows PowerShell:

$fileInfo = Get-ChildItem $MyFilePath
Write-Host "My file is: $(fileInfo.Name)"

...and this on PowerShell Core:

$fileInfo = Get-ChildItem $MyFilePath
Write-Host "My file is: $(fileInfo.FullName)"

The reason they moved from using .Name to .FullName for the default implementation of ToString appears to be something related to security because relative paths could be tinkered with.

The reason it's a best practice to explicitly convert objects into strings instead of relying on the object to figure out how to convert itself into a string is because of exactly this scenario, the implementation could change and leave you up a creek.

tiberriver256
  • 519
  • 5
  • 14
  • (a) It is worth clarifying that your best practice is only worth heeding if your code must _also_ still run in the legacy _Windows PowerShell_ edition. (b) Stringifying `$fileInfo` isn't _always_ the same as `$fileInfo.Name` - sometimes it _is_ `$fileInfo.FullName`, which is what makes stringifying `FileInfo` / `DirectoryInfo` instances so treacherous - [this answer](https://stackoverflow.com/a/53400031/45375) summarizes when you get one vs. the other. – mklement0 Nov 11 '21 at 03:20
  • As an aside: I suggest not using `Write-Host` in sample code, as it might mislead beginners to think that (a) using a cmdlet is necessary to produce output in PowerShell (it isn't) and that (b) the cmdlet to use for producing output is `Write-Host` (it generally isn't, unless your intent is to _write directly to the screen_ (host) rather than to _output data_). – mklement0 Nov 11 '21 at 03:20
  • Probably shouldn't have used `Write-Host`, but it helped me narrow down the issue a bit. To make sure I understand my own problem correctly though, I don't believe I'm trying to or want to convert the object to string anyway. My code is actually more like this: `(gci -LiteralPath $Path -Filter '*.csv' -File).ForEach({CustomFunction $_})`. The first parameter of `CustomFunction` is `param([System.IO.FileInfo]$inputFile)` which is also mandatory. In essence though, I think I'm expecting to receive the object and maybe I haven't been this entire time, but I thought I was – immobile2 Nov 11 '21 at 15:26
  • That scenario should be fine @immobile2. You'd get weirdness if your param looked like this though: `param([string]$inputFileName)` since that effectively calls `toString` as well which is when you run into the problems you were asking about. – tiberriver256 Nov 11 '21 at 16:00
  • The point about not relying on implicit stringification is a good one in general, though in this case there definitely won't be further changes. Consistently and therefore _predictably_ stringifying to the _full_ path is what .NET Framework (Windows PowerShell) should arguably always have done too. – mklement0 Nov 11 '21 at 16:21
  • But the larger issue here is PowerShell's parameter binding: There _is_ a predictable mechanism that doesn't rely on stringification and so also works robustly in WinPS: passing `FileInfo` instances _as pipeline input_, where the provider-supplied `.PSPath` property value is bound to `-LiteralPath`, which is guaranteed to be a full path. Unfortunately - and surprisingly - this only works _via the pipeline_, and not when passing such an instance _as an argument_, which is an awkward general asymmetry discussed in [GitHub issue #6057](https://github.com/PowerShell/PowerShell/issues/6057). – mklement0 Nov 11 '21 at 16:22
  • 1
    Thanks for the links and help, I feel dumb now because the issue wasn't the `FileInfo` object at all...it was the fact I was using `Import-Csv` and lazily passing `$inputFile` which got stringified as you mention. – immobile2 Nov 11 '21 at 17:53