Cannot pipe to Get-Content from binary module using FileInfo

Viewed 157

I am having a hard time piping a file to Get-Content (version PS 5.1).

Module code:

[Cmdlet(VerbsDiagnostic.Test, "FilePiping")]
public class PSTestFilePiping : PSCmdlet
{
    [Parameter(Position = 0, Mandatory = true)]
    public string FileName { get; set; }

    protected override void ProcessRecord()
    {
        WriteObject(new System.IO.FileInfo(FileName));
    }
}

Commands:

$x = Test-FilePiping .\readme.txt

$y = gci .\readme.txt

(I am using a relative path for simplicity)

Both $x and $y are a FileInfo object according to GetType():

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

Yet I cannot do:

$x | gc

The input object cannot be bound to any parameters for the command either because the command does not take pipeline input or the input and its properties do not match any of the parameters that take pipeline input.

but I can do:

$y | gc

This has me flummoxed.

Where did I go wrong?

3 Answers

In addition to @santiago-squarzon's helpful answer, there are two things worth noting here:

  • PowerShell commands like Get-Item and Get-ChildItem are not just made for the FileSystem so return information for the relevant provider, which is where values like PSPath come from.
  • Targeting a relative path with new FileInfo() will probably not find the file you are expecting, so we should get the full path just in case.

This can be achieved in your cmdlet so that it returns the PSPath property necessary for Get-Content.

Sample:

dotnet new classlib -o PSSample -n PSSample
cd PSSample
dotnet add package PowerShellStandard.Library --version 5.1.0
code . # I am using VSC - open your editor in the directory.
# Set target framework to netstandard2.0 in .csproj
# Copy and paste below in to Class1.cs
dotnet restore
dotnet build
ipmo .\bin\Debug\netstandard2.0\PSSample.dll
Test-FilePiping -FileName .\Class1.cs | FL *
Test-FilePiping -FileName .\Class1.cs | Get-Content

Cmdlet class:

using System;
using System.IO;
using System.Linq;
using System.Management.Automation;

namespace PSSample
{
    [Cmdlet(VerbsDiagnostic.Test, "FilePiping")]
    public class PSTestFilePiping : PSCmdlet
    {
        [Parameter(Position = 0, Mandatory = true)]
        public string FileName { get; set; }

        protected override void ProcessRecord()
        {
            string filePath = GetRelativePath(FileName);
            var item = InvokeProvider.Item.Get(filePath);
            WriteObject(item, true);    // true enumerates the collection, because InvokeProvider.Item.Get() returns a collection.
        }

        protected string GetRelativePath(string path)
        {
            string currentDir = GetVariableValue("PWD").ToString();

            if (Path.IsPathRooted(path) == true || path.StartsWith("\\", StringComparison.CurrentCulture))
            {
                // Nothing to see here.
            }
            else
            {
                if (path == ".")
                {
                    path = currentDir;
                }
                else if (path.StartsWith("..", StringComparison.CurrentCulture))
                {
                    path = Path.Combine(
                        string.Join("\\",
                            currentDir.Split('\\').Take(currentDir.Split('\\').Count() - path.Split('\\').Count(p => p == "..")).ToArray()
                        ),
                        string.Join("\\", path.Split('\\').Where(f => f != "..").ToArray()));
                }
                else if (path.StartsWith(".", StringComparison.CurrentCulture))
                {
                    path = Path.Combine(currentDir, path.Substring(2));
                }
                else
                {
                    path = Path.Combine(currentDir, path);
                }
            }

            return path;
        }
    }
}

Output:

PS C:\Code\PSSample> Test-FilePiping -FileName .\Class1.cs | FL *


PSPath            : Microsoft.PowerShell.Core\FileSystem::C:\Code\PSSample\Class1.cs
PSParentPath      : Microsoft.PowerShell.Core\FileSystem::C:\Code\PSSample
PSChildName       : Class1.cs
PSDrive           : C
PSProvider        : Microsoft.PowerShell.Core\FileSystem
PSIsContainer     : False
Mode              : -a----
VersionInfo       : File:             C:\Code\PSSample\Class1.cs
                    InternalName:
                    OriginalFilename:
                    FileVersion:
                    FileDescription:
                    Product:
                    ProductVersion:
                    Debug:            False
                    Patched:          False
                    PreRelease:       False
                    PrivateBuild:     False
                    SpecialBuild:     False
                    Language:

BaseName          : Class1
Target            : {}
LinkType          :
Name              : Class1.cs
Length            : 1947
DirectoryName     : C:\Code\PSSample
Directory         : C:\Code\PSSample
IsReadOnly        : False
Exists            : True
FullName          : C:\Code\PSSample\Class1.cs
Extension         : .cs
CreationTime      : 02/05/2021 13:03:22
CreationTimeUtc   : 02/05/2021 12:03:22
LastAccessTime    : 02/05/2021 13:04:13
LastAccessTimeUtc : 02/05/2021 12:04:13
LastWriteTime     : 02/05/2021 13:04:00
LastWriteTimeUtc  : 02/05/2021 12:04:00
Attributes        : Archive



PS C:\Code\PSSample> Test-FilePiping -FileName .\Class1.cs | Get-Content
using System;
using System.IO;
using System.Linq;
using System.Management.Automation;

namespace PSSample
{
    [Cmdlet(VerbsDiagnostic.Test, "FilePiping")]
    public class PSTestFilePiping : PSCmdlet
    {
        [Parameter(Position = 0, Mandatory = true)]
        public string FileName { get; set; }

        protected override void ProcessRecord()
        {
            string filePath = GetRelativePath(FileName);
            var item = InvokeProvider.Item.Get(filePath);
            WriteObject(item, true);    // true enumerates the collection, because InvokeProvider.Item.Get() returns a collection.
        }

        protected string GetRelativePath(string path)
        {
            string currentDir = GetVariableValue("PWD").ToString();

            if (Path.IsPathRooted(path) == true || path.StartsWith("\\", StringComparison.CurrentCulture))
            {
                // Nothing to see here.
            }
            else
            {
                if (path == ".")
                {
                    path = currentDir;
                }
                else if (path.StartsWith("..", StringComparison.CurrentCulture))
                {
                    path = Path.Combine(
                        string.Join("\\",
                            currentDir.Split('\\').Take(currentDir.Split('\\').Count() - path.Split('\\').Count(p => p == "..")).ToArray()
                        ),
                        string.Join("\\", path.Split('\\').Where(f => f != "..").ToArray()));
                }
                else if (path.StartsWith(".", StringComparison.CurrentCulture))
                {
                    path = Path.Combine(currentDir, path.Substring(2));
                }
                else
                {
                    path = Path.Combine(currentDir, path);
                }
            }

            return path;
        }
    }
}

Hopefully this helps you identify your error:

PS /> 'Hello world!' > test.txt
PS /> $reader = [System.IO.FileInfo]::new('test.txt')

If I try to pipe this object directly to Get-Content it will throw with the same error you're getting:

Using this:

PS /> $reader | Get-Content

Or this:

PS /> $reader.FullName | Get-Content

Results in:

Get-Content: The input object cannot be bound to any parameters for the command either because the command does not take pipeline input or the input and its properties do not match any of the parameters that take pipeline input.

This is because none of the properties returned by [System.IO.FileInfo] matches those paremeters accepted as ValueFromPipelineByProperyName by Get-Content:

PS /> $reader | gm -MemberType Property

   TypeName: System.IO.FileInfo

Name              MemberType Definition
----              ---------- ----------
Attributes        Property   System.IO.FileAttributes Attributes {get;set;}
CreationTime      Property   datetime CreationTime {get;set;}
CreationTimeUtc   Property   datetime CreationTimeUtc {get;set;}
Directory         Property   System.IO.DirectoryInfo Directory {get;}
DirectoryName     Property   string DirectoryName {get;}
Exists            Property   bool Exists {get;}
Extension         Property   string Extension {get;}
FullName          Property   string FullName {get;}
IsReadOnly        Property   bool IsReadOnly {get;set;}
LastAccessTime    Property   datetime LastAccessTime {get;set;}
LastAccessTimeUtc Property   datetime LastAccessTimeUtc {get;set;}
LastWriteTime     Property   datetime LastWriteTime {get;set;}
LastWriteTimeUtc  Property   datetime LastWriteTimeUtc {get;set;}
Length            Property   long Length {get;}
Name              Property   string Name {get;}

However if we create an object with any of the properties I mentioned in my comment, Get-Content will accept it as input and read it without any problems:

PS /> [PSCustomObject]@{ Path = $reader.FullName } | Get-Content
Hello world!

PS /> [PSCustomObject]@{ LiteralPath = $reader.FullName } | Get-Content
Hello world!

PS /> [PSCustomObject]@{ PSPath = $reader.FullName } | Get-Content
Hello world!

Get-ChildItem works fine because the object returned has the PSPath property.

The best way to identify which parameters are accepted as ValueFromPipelineByProperyName is to rely on MS Docs:

-LiteralPath
Type String[]
Aliases PSPath, LP
Position Named
Default value None
Accept pipeline input True
Accept wildcard characters False

To complement the existing, helpful answers with some background information:

It is the PowerShell FileSystem provider that decorates the System.IO.FileSystemInfo instances (both System.IO.FileInfo and System.IO.DirectoryInfo) that Get-ChildItem and Get-Item emit with additional properties such as PSPath (among several others [1]), using PowerShell's ETS (Extended Type System).

The .PSPath property values of pipeline input objects are what bind to the -LiteralPath parameter of cmdlets such as Get-Content, due to said parameter being declared with the ValueFromPipelineByPropertyName property and LiteralPath being decorated with an [Alias("PSPath")] attribute; that is, PSPath is an alias (another name for) LiteralPath.

As of this writing, the PowerShell providers add these ETS properties as instance properties, which is the reason that a directly constructed System.IO.FileInfo does not have these properties, resulting in the symptom you saw (the absence of the .PSPath property prevented binding to the -LiteralPath parameter).

If the providers were to use type-level ETS properties (associated with the .NET type itself rather than with specific instances of it), this problem would go away, as such properties would then surface irrespective of how an instance was constructed.

See GitHub issue #4347 for a discussion.


[1] The full list of added properties is: PSPath, PSParentPath, PSChildName, PSDrive, PSProvider, PSIsContainer, Mode, BaseName, Target, LinkType.

Related