Automate PowerShell module creation the smart way

Using Export-ScriptsToModule function :)

Automate PowerShell module creation the smart way

I will start with little warning. This is my first blog post ever and my english is far from perfect, so please be nice :D

In this post I will show you, how to easily generate PowerShell modules from ps1 scripts (that contains PowerShell functions) using my PowerShell function Export-ScriptsToModule.

Why to store functions in ps1 script files instead of placing them directly into psm1 module, you can ask?

For me its about simplicity

  • working with separate ps1 is much easier in Visual Studio Code IDE thanks to shortcut CTRL + P, that allows you to easily switch between files just by typing their name
  • it is much easier to create and debug new functions
  • one ps1 per functions is much easier to read than one big module with several hundreds or even thousands of lines
  • if you have (same as me) some pre-commit checks, it is much easier to work with functions in separate files

TL;DR

Use my PowerShell function Export-ScriptsToModule like this:

  • download the Export-ScriptsToModule.ps1 script
  • open PowerShell console and:
    • dot source downloaded script (or just copy paste its content into the console)
    • run function Export-ScriptsToModule like
      Export-ScriptsToModule @{"<pathToFolderWithps1ScriptsContainingFunctions>" = "<pathToModuleFolderThatWillBeGenerated>"}
      
  • check the generated module at <pathToModuleFolderThatWillBeGenerated>
  • Requirements: there have to be some ps1 scripts with functions defined in them stored in <pathToFolderWithps1ScriptsContainingFunctions> for this to work :-)

And this is how it will look like


Why not just dot source the ps1 script files?

  • dot sourcing can be even 10x slower than importing well made module (see bellow what this means)
  • if you split your functions to different modules, just module, that contains function you want to use, will be imported by default (so again much faster)

So how to do it the right way?

Define each function in a separate identically titled ps1 file (i.e. Hello-World.ps1 will contain function Hello-World etc). And from such ps1's generate psm1 modules automatically :)

What to be aware of when you want generate module automatically:

  • it is unsafe to simply copy&paste ps1 content, because there can be some code outside the function, therefore by importing generated module, that code would be automatically run
  • you have to detect defined aliases in ps1 and make them available in the module by exporting them
    • in [alias("HelloWorld")] definition, so as by using Set-Alias
  • you have to detect functions names, so you can export them directly in module instead of using wildcard '*'
    • using * in Export-ModuleMember makes the module import slow
  • validate function syntax before export
  • if source ps1 scripts are located in GIT repository, use last committed version of the ps1 file, in case the file is modified (there is work in progress, so it is not safe to use actual script content in generated module yet)
  • replace invalid characters like en-dash or em-dash for regular dash
    • otherwise syntax checker will throw nonsense errors on you

PowerShell AST to the rescue!

Parsing PowerShell content using regex to get the right data would be quite hard, but luckily we have AST!

AST is abbreviation of Abstract Syntax Tree and it is built-in PowerShell feature for code analysis. In general it will create hierarchic tree of objects, representing given code. You can use it for example to extract various information from the code like variables, function definitions, function parameters, aliases etc.

For example:

  • to find variables defined in "C:\Scripts\ScriptWithVariables.ps1"

    $AST = [System.Management.Automation.Language.Parser]::ParseFile("C:\Scripts\ScriptWithVariables.ps1", [ref]$null, [ref]$null)
    $variable = $AST.FindAll( { $args[0] -is [System.Management.Automation.Language.VariableExpressionAst ] }, $true)
    $variable | Where-Object { $_.parent.left -or $_.parent.type -and ($_.parent.operator -eq 'Equals' -or $_.parent.parent.operator -eq 'Equals') }
    
  • to find function definitions in "C:\Scripts\ScriptWithFunction.ps1"

    $AST = [System.Management.Automation.Language.Parser]::ParseFile("C:\Scripts\ScriptWithFunction.ps1", [ref]$null, [ref]$null)
    $AST.FindAll( {
      param([System.Management.Automation.Language.Ast] $AST)
    
      $AST -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
      # Class methods have a FunctionDefinitionAst under them as well, but we don't want them.
      ($PSVersionTable.PSVersion.Major -lt 5 -or
          $AST.Parent -isnot [System.Management.Automation.Language.FunctionMemberAst])
    }, $false)
    
  • to find function #requires in "C:\Scripts\ScriptWithRequires.ps1"

$AST = [System.Management.Automation.Language.Parser]::ParseFile("C:\Scripts\ScriptWithRequires.ps1", [ref]$null, [ref]$null)
$AST.scriptRequirements
# will return something like
RequiredApplicationId :
RequiredPSVersion     :
RequiredPSEditions    : {}
RequiredModules       : {psasync}
RequiresPSSnapIns     : {}
RequiredAssemblies    : {}
IsElevationRequired   : False
  • to find function aliases defined as [Alias("Some-FunctionAlias")] in "C:\Scripts\ScriptWithAlias.ps1"

    $AST = [System.Management.Automation.Language.Parser]::ParseFile("C:\Scripts\ScriptWithAlias.ps1", [ref]$null, [ref]$null)
    $AST.FindAll( {
          param([System.Management.Automation.Language.Ast] $AST)
    
          $ast -is [System.Management.Automation.Language.AttributeAst]
      }, $true) | ? { $_.parent.extent.text -match '^param' } | Select-Object -ExpandProperty PositionalArguments | Select-Object -ExpandProperty Value -ErrorAction SilentlyContinue # filter out aliases for function parameters
    
  • to find function aliases defined by Set-Alias in "C:\Scripts\ScriptWithAlias.ps1"

    $functionName = "Hello-World"
    $AST = [System.Management.Automation.Language.Parser]::ParseFile("C:\Scripts\ScriptWithAlias.ps1", [ref]$null, [ref]$null)
    $AST.EndBlock.Statements | ? { $_ -match "^\s*Set-Alias .+" -and $_ -match [regex]::Escape($functionName) } | % { $_.extent.text }
    

If you are interested in more examples of AST usage, check content of pre-commit.ps1. And search there functions: _getParameterAST, _getAliasAST, _getFunctionAST


And what about that GIT part?

First you have to recognize files, that are not ready for production (were modified since last pushed commit, or aren't commited yet)

# uncommited changed files
git ls-files -m --full-name
# untracked files
git ls-files --others --exclude-standard --full-name

Than you have to replace such files with their production ready variant (by getting content of last commited version from GIT) via

git show HEAD:/repository/scripts/someModifiedScript.ps1

So what is the solution?

You can use my function Export-ScriptsToModule and use it to easily create your own modules :)

  • check TL;DR section for more information

TIP If you want to automate this even further use my CI/CD PowerShell solution which uses same function in background and helps you to automate whole PowerShell content lifecycle. I will cover it in some following article.

If you have ANY questions, don't hesitate to contact me on Twitter

Did you find this article valuable?

Support Ondrej Sebela by becoming a sponsor. Any amount is appreciated!