Powershell: Classes, modules, fixing behaviour and scope

Created on 16 Mar 2020  路  14Comments  路  Source: PowerShell/PowerShell

At present there are multiple open issues (inc #6652, #2963, and #2964, #2449) which overlap with this one, but don't completely cover it and if my understanding of the facts is badly lacking, or I haven't found the key item among the issues I'm more than than happy for the powers that be to close this one.

Scenario. I want to implement a Parameter validation attribute class to support functions in a module.
If I write the class in C# and load it with add-type my understanding is that it is compiled to a temporary DLL and loaded and available system wide, whatever method is used to load it .

If I write the class in PowerShell it is not exported by the module if it is placed in the PSM1 file, or a file dot sourced by the PSM1. Here is the class

using namespace System.Collections
using namespace System.Collections.Generic
using namespace System.Drawing.Printing
using namespace System.Management.Automation
#Validator as PS Class [ValidatePrinterExistsAttribute()]
class ValidatePrinterExistsAttribute : ValidateArgumentsAttribute {
    [void] Validate([object]$Argument, [EngineIntrinsics]$EngineIntrinsics) {
        if(-not ($Argument -in [PrinterSettings]::InstalledPrinters)) {
          Throw [ParameterBindingException]::new("'$Argument' is not a valid printer name.")
        }
    }
}

and here is a related class in C#

Add-type  -ReferencedAssemblies "System.Drawing.Common", "System.Linq","System.Management.Automation"  @"
using System.Drawing.Printing;
using System.Linq;
using System.Management.Automation;
public class ValidPrinterSetGenerator : IValidateSetValuesGenerator
{
    public string[] GetValidValues()
    {
        return PrinterSettings.InstalledPrinters.Cast<string>().ToArray();
    }
}
"@  -WarningAction SilentlyContinue -CompilerOptions "/nowarn:CS1701,CS1702"

(I found I had to use /NoWarn to supress issues which suggested some .NET Core parts were mismatched) .
After loading these via the PSM file the C# class is available and [ValidPrinterSetGenerator]::new().GetValidValues() returns a the list of printers, but a function in the module which uses the PS class for one of its parameters gives this error

#501 PS7 C:\Users\mcp\Documents\PowerShell\classTest>.\load2.ps1
InvalidOperation: C:\Users\mcp\Documents\PowerShell\classTest\classtest.psm1:19:9
Line |
  19 |          [ValidatePrinterExistsAttribute()]
     |          ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     | Cannot find the type for custom attribute 'ValidatePrinterExistsAttribute'. Make sure that the assembly that contains this type is loaded.

As I understand it the class might be available to things wholly within the module, but the validation attribute needs be accessible outside the module's scope. (Parameter stuff being done outside the module)

The is a workaround: add the classes to the ScriptsToProcess in the module manifest.

There is a secondary issue even with workaround. If the module is loaded usingimport-modulein a script the validation works but the PS class is invisible thereafter unless the script is dot-sourced. However if the script uses using module or #requires -module the class is available without dot-sourcing. I think it is broadly OK to say load modules which require at least V5 because they have classes with the using syntax.

Given the length of time some of issues have been open, I'm wondering if the intent is to

  1. Make import and using/requires consistent and / or
  2. Provide a way to export PS classes from a module, rather than pre-loading them and/or
  3. Provided some more detailed documentation of the status quo ?
Issue-Enhancement Resolution-Answered WG-Engine

Most helpful comment

This looks like dup #1762.

All 14 comments

Add-Type uses "Microsoft.PowerShell.Commands.AddType.AutoGeneratedTypes" as namespace. So "[Microsoft.PowerShell.Commands.AddType.AutoGeneratedTypes.ValidatePrinterExistsAttribute()]" could work.

Add-Type uses "Microsoft.PowerShell.Commands.AddType.AutoGeneratedTypes" as namespace. So "[Microsoft.PowerShell.Commands.AddType.AutoGeneratedTypes.ValidatePrinterExistsAttribute()]" could work.

Sadly, not

>[Microsoft.PowerShell.Commands.AddType.AutoGeneratedTypes.ValidatePrinterExistsAttribute] 
InvalidOperation: Unable to find type [Microsoft.PowerShell.Commands.AddType.AutoGeneratedTypes.ValidatePrinterExistsAttribute].

But it is present to do the validation

>test2 fax
fax
>test2 foobar
test2: Cannot validate argument on parameter 'name'. 'foobar' is not a valid printer name.

@jhoneill Please add more strong repo steps for both cases including the module sources - it would safe time of reviewers. Thanks!

@jhoneill Please add more strong repo steps for both cases including the module sources - it would safe time of reviewers. Thanks!

Simple demo. There are comments in the PSM1 / PSD1 to show which lines to enable to change where the loading happens.
You can import the module and run test2 <> or try doing that with load.ps1 or by dot sourcing load.ps1 and comparing the results.

As well as test2.ps1 You can compare the c# class behaviour e.g.
[ValidPrinterSetGenerator]::new().GetValidValues()
and ps class behaviour e.g.
[ValidatePrinterExistsAttribute]

classtest.zip

Thanks !!

  1. Make import and using/requires consistent and / or

They've mentioned a few times that requiring using was a design decision because classes are largely a parse time concept, so a parse time import was needed.

  1. Provide a way to export PS classes from a module, rather than pre-loading them and/or

If the classes are defined within the psm1, using module will import them. This is currently the only "proper" way to define classes to be exported.

This looks like dup #1762.

  1. Make import and using/requires consistent and / or

They've mentioned a few times that requiring using was a design decision because classes are largely a parse time concept, so a parse time import was needed.

  1. Provide a way to export PS classes from a module, rather than pre-loading them and/or

If the classes are defined within the psm1, using module will import them. This is currently the only "proper" way to define classes to be exported.

Well, partly. If the classes are in the PSM1 file, instead of Dot-Sourced into I can have this as my PSM

using namespace System.Drawing.Printing
using namespace System.Management.Automation
using namespace System.Management.Automation.Language
class ValidatePrinterExistsAttribute : ValidateArgumentsAttribute {
    [void] Validate([object]$Argument, [EngineIntrinsics]$EngineIntrinsics) {
        if(-not ($Argument -in [PrinterSettings]::InstalledPrinters)) {
          Throw [ParameterBindingException]::new("'$Argument' is not a valid printer name.")
        }
    }
}

function test2 {
param (
    [ValidatePrinterExistsAttribute()]
    $name
)
$name
}

Then
ipmo .\classtest.psm1 ; test2 fax ; test2 foo; [ValidatePrinterExistsAttribute]
validates _but_ can't find the attribute class and as you say

using module .\classtest.psm1
test2 fax ; test2 foo; [ValidatePrinterExistsAttribute]
Validates and does find the attribute class

OK. So 2 learnings

  1. Put the classes directly into the PSM . Dot Sourcing them in does not work. Those people who like a minimal PSM1 file will have to learn to cope.
  2. Using loads more than import-module does (and I thinkrequires` is like using, not like import

Pasting the using ... & test... lines into load2.ps1 and running that ... validates and finds the class but after loading

>test2 foo
test2: Cannot validate argument on parameter 'name'. 'foo' is not a valid printer name.
> [ValidatePrinterExistsAttribute]
InvalidOperation: Unable to find type [ValidatePrinterExistsAttribute].

The module's function is there and calls the class, but the class isn't visible outside the script which loaded the module... unless I dot source load2.ps1. So Dot sourcing is required in one place, and fails in another.

This looks like dup #1762.
Overlaps but not exactly a dup :-)
The difference by adding classes to the PSM that @SeeminglyScience called out is new (and very useful) information.
[Might have to blog that]

This looks like dup #1762.
Overlaps but not exactly a dup :-)
The difference by adding classes to the PSM that @SeeminglyScience called out is new (and very useful) information.
[Might have to blog that]

Nah it's the same thing I think. My guess is that the bug is related to when the resolution of the ITypeName is cached. It applies to most instances of trying to use an attribute that is loaded (even with Add-Type -TypeDef, even with Add-Type -Path to a dll). Type resolution for attributes outside of the built in ones is sorta borked.

Can confirm, I've seen a lot of funkiness when using custom attributes. Sometimes it's fine to call them NameAttribute and PS picks it up fine with [Name()], other times it refuses to find it and you've gotta just call the class itself [Name].

Among many other oddities that are frustrating as all heck to reliably reproduce.

I set "answered" since we have a workaround and cross-reference with meta-issue #6652.

@vexx32 yes saw your comments on 1762 , and had the same with where you can omit 'attribute' and next thing you find you can't - and agree it's full of oddities.

@iSazonov Makes sense :-)

@vexx32 It might amused you that after this a penny dropped that one of the problems I was getting might be because I'd removed "attribute" ... just put it back in into close to 100 places where I'd replaced a dynamic parameters with static ones and a validate class and now things behave (as above) without . sourcing

If the classes are defined within the psm1, using module will import them. This is currently the only "proper" way to define classes to be exported.

From what I can tell, this is apparently still true. But this doesn't make any sense to me. I download a psm1 file from my repo as pure text over TLS, and I convert it to a dynamic module via New-Module and then import it into memory with Import-Module. Now I can access the functions as I wish, and when I am done, I have nothing to cleanup on the file system. Why cannot I do the same thing with a class? I should be able to download the class file, whether in a psm1 or ps1 file, as pure text over TLS and then import it easily into memory for instantiation. Logically, it makes sense to just import it with a module OR import it with another such command. For example:

$fooClass = New-Class -ScriptBlock $fooClassAsText
$fooClass | Import-Class
$myFooObj = [Foo]::new()  #If the class import was unsuccessful, I'll get an exception here telling me that Foo doesn't exist
Was this page helpful?
0 / 5 - 0 ratings