A module groups a set of commands together, most often around a common system, service, or purpose. Modules were introduced with PowerShell 2.
A module written in PowerShell can contain functions, classes, enumerations, and DSC resources, and it may define aliases and include variables to include in the global scope.
There are several different common styles or layouts used when authoring modules. This chapter explores these different approaches, starting with the simplest.
This chapter explores the following topics:
This chapter uses the following modules, which can be installed from the PowerShell Gallery:
The module created in this chapter is available on GitHub: https://github.com/PacktPublishing/Mastering-Windows-PowerShell-Scripting-Fourth-Edition/tree/master/Chapter20/LocalMachine.
The repository includes the different layouts discussed in this chapter.
A module most often consists of one or more functions. Typically, you create modules in one or more files starting with a psm1
file. The psm1
file is known as the root module.
The root module has a psm1
extension and is otherwise like any other script file in PowerShell. The root module file is named after the module.
The psm1
file can contain all the module content directly; nothing else is required to create a module.
A module requires content. Chapter 19, Classes and Enumerations, ended by creating a class-based DSC resource to manage the computer description property. You'll rebuild this content of the resource and use it as the basis for creating a module during this chapter.
Here is the first command to add to a file named LocalMachine.psm1
:
function Get-ComputerDescription {
[CmdletBinding()]
[OutputType([string])]
param ( )
$getParams = @{
Path = 'HKLM:SYSTEMCurrentControlSetServicesLanmanServerParameters'
Name = 'srvcomment'
}
Get-ItemPropertyValue @getParams
}
Once saved, you can import the module file. The following command assumes the file is in the current directory in PowerShell:
Import-Module .LocalMachine.psm1
The command will be displayed in the ExportedCommands
property when running Get-Module
:
PS> Get-Module LocalMachine
ModuleType Version PreRelease Name ExportedCommands
---------- ------- ---------- ---- ----------------
Script 0.0 LocalMachine Get-ComputerDescription
The ModuleType
shows as Script
because the module is defined by a psm1
file. If ExportedCommands
is empty, there may be an error in the module file. Attempting to import the module should show any errors.
You can run the command if the module is imported. If a computer description is set, it will be displayed, otherwise an error will be displayed. You can optionally set a description by running the following command as administrator:
$params = @{
Path = 'HKLM:SYSTEMCurrentControlSetServicesLanmanServerParameters'
Name = 'srvcomment'
Value = 'Computer description'
}
New-ItemProperty @params
As you add more functions to the psm1
file, additional commands will be displayed in ExportedCommands
. By default, all functions are exported from the module, making them available to anyone using the module.
You can use the Export-ModuleMember
command inside a psm1
file to explicitly define what is exported from a module.
At this point, the LocalMachine
module only contains one command, and that command is expected to be visible to the end user.
As the module grows, some of the code will start to repeat, and functions might be added to the module to reduce repetition. Such functions might be expected to be internal to the module, not visible to a user of the module.
Functions that are exported are often referred to as public functions; functions that are not exported are known as private functions.
The following change to LocalMachine.psm1
introduces a function that will be shared inside the module and is not intended to be seen by users of the module.
The private function anticipates that the registry path and value name parameters will be used by other functions in the module later:
function GetRegistryParameter {
[CmdletBinding()]
[OutputType([Hashtable])]
param ( )
@{
Path = 'HKLM:SYSTEMCurrentControlSetServicesLanmanServerParameters'
Name = 'srvcomment'
}
}
The private function GetRegistryParameter
is used by the Get-ComputerDescription
command, as the following shows:
function Get-ComputerDescription {
[CmdletBinding()]
[OutputType([string])]
param ( )
$getParams = GetRegistryParameter
Get-ItemPropertyValue @getParams
}
GetRegistryParameter
is also used by both Set-ComputerDescription
and Remove-ComputerDescription
. The short Remove-ComputerDescription
function is:
function Remove-ComputerDescription {
[CmdletBinding(SupportsShouldProcess)]
param ( )
$removeParams = GetRegistryParameter
if ($PSCmdlet.ShouldProcess(
'Removing computer description')) {
Remove-ItemProperty @removeParams
}
}
And finally, you can add the Set-ComputerDescription
command to complete the functions for this version of the module. Note that Set-ComputerDescription
requires administrator rights to execute:
function Set-ComputerDescription {
[CmdletBinding(SupportsShouldProcess)]
[OutputType([string])]
param (
[Parameter(Mandatory, Position = 1, ValueFromPipeline)]
[string]$Description
)
process {
if ($pscmdlet.ShouldProcess(
'Removing computer description')) {
$setParams = GetRegistryParameter
$params = @{
Type = 'String'
Value = $Description
Force = $true
}
try {
New-ItemProperty @setParams @params
} catch {
$PSCmdlet.ThrowTerminatingError($_)
}
}
}
}
The Set-ComputerDescription
command adds a few features first explored in Chapter 17, Scripts, Functions, and Script Blocks. First it has a mandatory parameter for the new description. That Description
parameter accepts values from the pipeline.
Both the Remove-ComputerDescription
and Set-ComputerDescription
commands implement SupportsShouldProcess
, enabling the use of the -WhatIf
switch parameter. Implementing SupportsShouldProcess
is an encouraged practice for commands that change state.
If the functions are saved to LocalMachine.psm1
, and the module is imported, then the new command will show in ExportedCommands
:
PS> Import-Module .LocalMachine.psm1 -Force -Verbose
VERBOSE: Loading module from path 'C:workspaceLocalMachine.psm1'.
VERBOSE: Importing function 'Get-ComputerDescription'.
VERBOSE: Importing function 'Remove-ComputerDescription'.
VERBOSE: Importing function 'Set-ComputerDescription'.
PS> Get-Module -Name LocalMachine |
>> ForEach-Object ExportedCommands
Key Value
--- -----
Get-ComputerDescription Get-ComputerDescription
GetRegistryParameters GetRegistryParameters
Remove-ComputerDescription Remove-ComputerDescription
Set-ComputerDescription Set-ComputerDesciption
You used the Force
parameter with Import-Module
in the previous example to overwrite the previously imported version of the module. If a module is already imported, Import-Module
will not, by default, reimport that module.
Naming private commands
GetRegistryParameters
follows my own convention for naming private commands. Private commands always use an approved verb (see Get-Verb
) but omit the hyphen.
This convention is not mandatory; the naming of private commands is a personal preference.
Adding the Export-ModuleMember
command to the end of the LocalMachine.psm1
file will hide the GetRegistryParameters
function from view:
Export-ModuleMember -Function @(
'Get-ComputerDescription'
'Remove-ComputerDescription'
'Set-ComputerDescription'
)
Importing the module again shows the change to ExportedCommands
:
PS> Import-Module .LocalMachine.psm1 -Force
PS> Get-Module -Name LocalMachine |
>> ForEach-Object ExportedCommands
Key Value
--- -----
Get-ComputerDescription Get-ComputerDescription
Remove-ComputerDescription Remove-ComputerDescription
Set-ComputerDescription Set-ComputerDesciption
Export-ModuleMember
can be used to define which functions, cmdlets, variables, and aliases are exported from a module. You can also define these options by using a module manifest.
The module manifest is a PowerShell data file (psd1
file, a Hashtable stored in a file) that contains metadata for a module.
The manifest includes information such as the module description, PowerShell version and edition compatibility, version number, commands, aliases, variables that should be exported, and so on.
Help for either the New-ModuleManifest
or Update-ModuleManifest
commands will show the possible keys. A manifest created using New-ModuleManifest
includes comments describing the purpose of each field.
The use of a manifest is mandatory if a module is published on the PowerShell Gallery or any other repository when using the Publish-Module
command.
The following code creates a module manifest for the LocalMachine
module:
$params = @{
Path = '.LocalMachine.psd1'
Description = 'Local machine configuration'
RootModule = 'LocalMachine.psm1'
ModuleVersion = '1.0.0'
FunctionsToExport = @(
'Get-ComputerDescription'
'Remove-ComputerDescription'
'Set-ComputerDescription'
)
PowerShellVersion = '5.1'
CompatiblePSEditions = @(
'Core'
'Desktop'
)
}
New-ModuleManifest @params
In the preceding example, RootModule
is the full name of the psm1
file. This path, and all other paths used in the manifest, are relative to the module manifest file.
RootModule
is used to determine the module type displayed when running the Get-Module
command:
RootModule
is set to a psm1
file (as in our example), the module type will be script
RootModule
is set to a dll
file, the module type will be binary
RootModule
is not set or is set to a value with any other file extension, the module type will be manifest
The RootModule
property is not the only value that affects the module type. A module in PowerShell can have other modules nested inside. Nested modules are loaded via a NestedModules
property in the manifest:
NestedModules
is set, the module type will be manifest
(regardless of the RootModule
value)Other than affecting the module type value, NestedModules
is beyond the scope of this chapter.
The newly created module manifest will, by default, set CmdletsToExport
, VariablesToExport
, and AliasesToExport
to *
. You can set these values to @()
if automatically exporting everything is not desirable.
You can use wildcards for any of the ToExport
properties, including FunctionsToExport
. For example, you could use the value *-*
to export public functions in the LocalMachine
module. Using a specific list helps the module autoloader in PowerShell import the module if one of the commands is used and the module has never been imported before.
This manifest replaces the functionality of the Export-ModuleMember
command.
PowerShell and PowerShellGet include several commands for working with the content of the module manifest.
The Test-ModuleManifest
command attempts to read and perform basic checks of the values used in the module manifest.
The command returns an object that represents data in the manifest. Import-PowerShellDataFile
can also be used to read the manifest if required.
If the PowerShellVersion
property in the manifest is reduced to 5.0
in LocalMachine.psd1
, Test-ModuleManifest
will raise an error pointing out that the CompatiblePSEditions
key is not available before PowerShell 5.1:
PS> Test-ModuleManifest .LocalMachine.psd1
Test-ModuleManifest: The module manifest 'C:WorkspaceLocalMachineLocalMachineLocalMachine.psd1' is specified with the CompatiblePSEditions key which is supported only on PowerShell version '5.1' or higher. Update the value of the PowerShellVersion key to '5.1' or higher, and try again.
ModuleType Version PreRelease Name ExportedCommands
---------- ------- ---------- ---- ----------------
Script 1.0.0 LocalMachine Get-ComputerDescription
Test-ModuleManifest
will also test paths used in the manifest. For example, if an absolute path is used for RootModule
, such as C:LocalMachine.psm1
, an error will be displayed. The RootModule
path must be a relative path.
The New-ModuleManifest
command can, but should not, be used to update the content of an existing manifest. Instead, you should use the Update-ModuleManifest
command.
You can use the Update-ModuleManifest
command from the PowerShellGet module to update the content of an existing manifest. However, you cannot use Update-ModuleManifest
to create a new manifest.
The advantage of using Update-ModuleManifest
is that it correctly handles writing to the PrivateData
section of the manifest. New-ModuleManifest
is unable to set values in this section.
You can use Update-ModuleManifest
to add a project URL and license information to the manifest, as shown here:
Update-ModuleManifest -Path .LocalMachine.psd1 -PrivateData @{
ProjectUri = 'https://github.com/indented-automation/LocalMachine'
LicenseUri = 'https://opensource.org/licenses/MIT'
}
Similarly, you can update the version number in the manifest; in this case, the major version is updated:
$path = '.LocalMachine.psd1'
$manifest = Test-ModuleManifest -Path $path
$newVersion = [Version]::new(
$manifest.Version.Major + 1,
$manifest.Version.Minor,
$manifest.Version.Build
)
Update-ModuleManifest -Path $path -ModuleVersion $newVersion
You may want to publish the module to a PowerShell repository at this point.
Use the Publish-Module
command to publish a module to a PowerShell repository.
The most common repository types are a NuGet feed (like the PowerShell Gallery), or a directory (often a file share). There are several free options available for creating a dedicated private PowerShell repository, including:
Each of these allows you to create a private repository for use within an organization. The repository would typically contain internally developed content or curated content that has been approved for internal use copied from public repositories.
To test publishing the LocalMachine
module, create a repository in a local directory:
New-Item ~PSLocal -ItemType Directory
Register-PSRepository -Name PSLocal -Source ~PSLocal
Repositories such as the PowerShell Gallery require an API key to authenticate the request.
Removing the PSLocal repository
When it is no longer required, the repository can be removed:
Unregister-PSRepository -Name PSLocal
You can now publish the module with the Publish-Module
command:
Publish-Module -Path . -Repository PSLocal
The Path
used with Publish-Module
, the current working directory in the preceding example, has a constraint: the path must be a directory and it must be named after the module.
That is, if the module is LocalMachine.psd1
, and the manifest's parent directory is named LocalMachine
, then publishing will likely succeed, showing a directory structure like the following:
ProjectRoot
| -- LocalMachine
| -- LocalMachine.psd1
| -- LocalMachine.psm1
Once published, a nupkg
file will be created in the PSLocal
directory, like in this example:
PS> Get-ChildItem ~PSLocal
Directory: C:UsersChrisPSLocal
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a--- 11/04/2021 12:00 3856 LocalMachine.2.0.0.nupkg
A versioned directory is also permitted, provided the parent of that is named after the module. This is part of the side-by-side versioning support introduced with PowerShell 5.0.
Before PowerShell 5, only one version of a module could be installed under each module path. The content of a Modules
directory was as shown below, which is like the structure used in the project repository:
Modules
| -- LocalMachine
| -- LocalMachine.psd1
| -- LocalMachine.psm1
Side-by-side versioning stores each version of a module in a versioned directory:
Modules
| -- LocalMachine
| -- 1.0.0
| -- LocalMachine.psd1
| -- LocalMachine.psm1
This allows more than one version of a module to be installed. Commands such as Import-Module
include parameters used to select a version.
This affects publishing a module in that a versioned directory is also permitted as the value for the Path
parameter of Publish-Module
. That is, if the project were laid out as the following illustrates:
ProjectRoot
| -- LocalMachine
| -- 1.0.0
| -- LocalMachine.psd1
| -- LocalMachine.psm1
Then the following command would succeed if executed from the ProjectRoot
directory:
$params = @{
Path = '.LocalMachine1.0.0'
Repository = 'PSLocal'
}
Publish-Module @params
Embedding a version number as shown in the previous example will satisfy the path constraint for Publish-Module
, but it is an unlikely directory structure to use when writing a module.
The LocalMachine
module only includes three functions and a module manifest at this point. As the module grows in complexity, attempting to keep writing the module in a single file becomes more difficult.
Multi-file layouts typically aim to split the content of a project up into smaller and easier to maintain files. The most common strategy is to create one file for each function, class, or enumeration.
Files are categorized and grouped together to make it easier to find parts of the module. For example, the LocalMachine
module might split up as follows:
ProjectRoot
| -- LocalMachine
| -- public
| | -- Get-ComputerDescription.ps1
| | -- Set-ComputerDescription.ps1
|
| -- private
| | -- GetRegistryParameter.ps1
| | -- TestComputerDescriptionValue.ps1
|
| -- LocalMachine.psd1
| -- LocalMachine.psm1
When split like this, LocalMachine.psm1
needs to change to load the content from those separate files.
Dot sourcing is used to load the content of a target file into the current scope. If used inside the psm1
file, each individual file will be loaded into the same scope as the psm1
file.
When the psm1
file runs, the $PSScriptRoot
variable (introduced in PowerShell 3) will be set to the directory the psm1
file is saved in. This can be used to create an expression to find the files that make up the module content:
'public', 'private' |
Resolve-Path -Path $PSScriptRoot -ChildPath { $_ } |
Get-ChildItem -Recurse -File -Filter *.ps1
Note that this cannot be run outside of a script (or script module); the $PSScriptRoot
variable will not exist.
Getting the module base
$PSScriptRoot
is specific to the file it is used in. Therefore, if one of the dot sourced files from a sub-directory requires access to the module base directory, it is less useful.
Instead, a property of the reserved variable, $MyInvocation
, can be used from any script or function within a module to get the folder for the module manifest file:
$MyInvocation.MyCommand.Module.ModuleBase
Each of the discovered files needs to be dot sourced. You can use the result of the preceding command in a loop or with ForEach-Object
:
'public', 'private' |
Resolve-Path -Path $PSScriptRoot -ChildPath { $_ } |
Get-ChildItem -Recurse -File -Filter *.ps1 |
ForEach-Object {
. $_.FullName
}
Explicitly naming the directories at the top level, that is, public
and private
, ensures that some control is maintained over the loading order of the parts of the module. For functions, this is unlikely to be required, but if classes are introduced, the order may become important.
This approach has a small risk in that it will load any other ps1
files present in the module directory regardless of whether they belong to the module.
An alternative, but a higher maintenance approach is to name the files to import instead of allowing any file at all to load. For example:
$private = 'GetRegistryParameter'
foreach ($item in $private) {
. '{0}private{1}.ps1' -f $PSScriptRoot, $item
}
$public = @(
'Get-ComputerDescription'
)
foreach ($item in $public) {
. '{0}public{1}.ps1' -f $PSScriptRoot, $item
}
Export-ModuleMember -Function $public
With this version, module content is loaded with an explicit name. Additional files that are erroneously placed in the module directory are not processed.
Dot sourcing module content is useful when a module is being developed. It allows a developer to realize the benefits of having module content split into separate files: content is easier to find, files are easier to read, and when source control is in use, changes are easier to review.
Conversely, modules split into lots of files are a little slower to load; potentially a lot slower if code signing is used. Code signing itself is beyond the scope of this chapter as few publicly available modules are signed. The topic is explored in the about_Signing
help topic.
Merging module content using a build process of some kind offers the best of both worlds. The module is split into files, making it easy to write and edit; the module is merged into a single file, making it quick to load and easy to sign.
The ModuleBuilder
module is capable of merging module content into a single file. The ModuleBuilder
module requires a build.psd1
file in the root of the module (adjacent to LocalMachine.psd1
). The build.psd1
file does not need to contain more than an empty Hashtable; it can be used to customize the merge process.
The following example instructs Build-Module
to place merged content in a build directory and includes a versioned directory that might be used with Publish-Module
:
@{
ModuleManifest = 'LocalMachine.psd1'
OutputDirectory = '../build'
VersionedOutputDirectory = $true
}
With the file present, you can use the following command to merge the module content:
Install-Module ModuleBuilder -Scope CurrentUser
Build-Module -SourcePath .LocalMachine
If the command is run from the same directory as build.psd1
, the SourcePath
argument can be omitted. For example:
Set-Location .LocalMachine
Build-Module
The resulting module file is placed in the output directory under the project directory. The output path is configurable.
ModuleBuilder
will generate a new module manifest based on the existing manifest. It will update FunctionsToExport
based on the content of the public directory, and it will fill AliasesToExport
if any of the commands use aliases.
As a module grows in complexity, it may be desirable to perform additional tasks during the build step. For example, you might add extra metadata to the module manifest, run tests, regenerate help files, or publish the module.
ModuleBuilder
will, by default, merge content from a subdirectory named classes
into the generated root module. However, ModuleBuilder
will not update the DscResourcesToExport
property in the module manifest.
The classes
directory in the module contains a version of the DSC class created in Chapter 19, Classes and Enumerations. The simplified version of the class is available on GitHub: https://github.com/PacktPublishing/Mastering-Windows-PowerShell-Scripting-Fourth-Edition/blob/master/Chapter20/LocalMachine/MultiFileWithModuleBuilder/LocalMachine/classes/ComputerDescription.ps1.
It is possible to dynamically update DscResourcesToExport
but finding the resources in the module requires an advanced technique. The following snippet uses the Abstract Syntax Tree or AST, which is explored in more detail in Chapter 21, Testing.
The snippet is saved in a build.ps1
file in the preceding repository in the MultiFileWithModuleBuilder
directory, the project root for this module layout. The build.ps1
file runs Build-Module
and then updates the missing information in the module manifest based on the search below. The Update-ModuleManifest
command from PowerShellGet
is used to fill in the missing values in the manifest.
The snippet can be run from the MultiFileWithModuleBuilder
directory:
using namespace System.Management.Automation.Language
# Find the root module
$rootModulePath = @{
Path = $pwd
ChildPath = 'build***.psm1'
}
$rootModule = Join-Path @rootModulePath | Resolve-Path
# These values do not need to be captured for this search process
$tokens = $errors = $null
$ast = [Parser]::ParseFile(
$rootModule,
[ref]$tokens,
[ref]$errors
)
$dscResourcesToExport = $ast.FindAll({
param ( $node )
$node -is [TypeDefinitionAst] -and
$node.IsClass -and
$node.Attributes.TypeName.FullName -contains 'DscResource'
}, $true).Name
The preceding code reads the content of the psm1
file generated by ModuleBuilder
, parses that file, and searches for every PowerShell class that has the DscResource
attribute. It outputs the names of the classes, which can then be used with the Update-ModuleManifest
command:
$moduleManifestPath = @{
Path = $pwd
ChildPath = 'build***.psd1'
}
# Find the module manifest
$moduleManifest = Join-Path @moduleManifestPath |
Get-Item |
Where-Object { $_.BaseName -eq $_.Directory.Parent.Name }
$updateParams = @{
Path = $moduleManifest
DscResourcesToExport = $dscResourcesToExport
}
Update-ModuleManifest @updateParams
The technique above is complex but shows how PowerShell can be used to create a generic process that can be used to work on different kinds of modules without building a specialized process every time.
Script modules loaded from psm1
files have a shared scope that you can access using the $Script:
scope modifier.
You can create variables in the module scope, which is the same as the script scope. Functions within the module may consume those variables. You can create such variables in the root module or when a command is run.
Helper functions can be created to provide obvious access to the variable content.
This approach is illustrated in the following example. This pattern can be used for modules that interact with services that require a connection or authentication. For example, a REST web service might require an explicit authentication step to acquire a time-limited token or key.
First, a function styled like the following Connect-Service
function establishes a script-scoped variable. The connection can capture an authentication token rather than representing a truly connected service (for instance, a connection to an SQL server). The implementation of the command depends on the needs of the service and the module:
function Connect-Service {
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[String]$Server
)
$Script:connection = [PSCustomObject]@{
Server = $Server
PSTypeName = 'ServiceConnectionInfo'
}
}
You can create a command to allow either another command in the module or the end user to get the stored connection object:
function Get-ServiceConnection {
[CmdletBinding()]
param ( )
if ($Script:connection) {
$Script:connection
} else {
throw [InvalidOperationException]::new(
'Not connected to the service'
)
}
}
Finally, the stored connection can be used by another function in the module to carry out the actions it needs:
function Get-ServiceObject {
[CmdletBinding()]
param (
[PSTypeName('ServiceConnectionInfo')]
$Connection = (Get-ServiceConnection)
)
}
Defining the connection as a parameter allows the user of the function to provide an explicit connection if more than one connection is to be used. The Connect-Service
function would have to be adjusted to accommodate such a scenario.
All functions and classes within a module can access script-scoped variables by using the scope modifier.
The script scope of a module can be accessed from outside the module to aid debugging by using the call operator.
The following snippet is used to demonstrate accessing module scope. The module consists of one public function, one private function, and a module-scoped variable.
The module is created in memory using the New-Module
command, which avoids the need to create a file to demonstrate the feature. Note that the newly created module is piped to Import-Module
, which ensures the module is accessible after import using Get-Module
:
New-Module SomeService {
function GetServiceConnection {
[CmdletBinding()]
param ( )
$Script:connection
}
function Connect-Service {
[CmdletBinding()]
param (
[String]$Name
)
$Script:connection = $Name
}
$Script:connection = 'DefaultConnection'
Export-ModuleMember -Function Connect-Service
} | Import-Module
By default, only the Connect-Service
function is available, as shown here:
PS> Get-Command -Module SomeService
CommandType Name Version Source
----------- ---- ------- ------
Function Connect-Service 0.0 SomeService
The value of the script-scoped connection variable may be retrieved as follows. The module used in this manner must be imported prior to use:
PS> & (Get-Module SomeService) { $connection }
DefaultConnection
This technique can be used with any module, but it likely only makes sense when used with a script module (or a manifest module that includes script modules).
If the Connect-Service
command is used, the value returned by the preceding command will change. The same approach may be used to interact with functions that are not normally exported by the module. For example, the GetServiceConnection
function can be called:
& (Get-Module SomeService) { GetServiceConnection }
Finally, as the command is executing in the scope of the module, this technique may be applied to list the commands within the module, as follows:
PS> & (Get-Module SomeService) { Get-Command -Module SomeService }
CommandType Name Version Source
----------- ---- ------- ------
Function Connect-Service 0.0 SomeService
Function GetServiceConnection 0.0 SomeService
This technique is useful when debugging modules that heavily utilize module scope.
Module scope is also used when creating classes and enumerations within a module.
PowerShell classes and enumerations are not exported from a module and are not made available to an end user when Import-Module
is used, meaning they cannot be used outside of the module scope by default.
Classes and enumerations are only available outside of the module scope if a using module
statement is used.
You can use classes as return values from functions within a module without changing scope. Enumeration values can be supplied as arguments for function parameters via the value name without needing direct access to the enumeration
type. This is shown in the following example:
New-Module ModuleWithEnum {
enum ParameterValues {
ValueOne
ValueTwo
}
function Get-Something {
[CmdletBinding()]
param (
[ParameterValues]$Parameter
)
}
} | Import-Module
Get-Something -Parameter ValueOne
The preceding Get-Something
function offers tab completion with names from the enumeration. A string, such as ValueOne
, can refer to a value in the enumeration. The enumeration itself does not have to be made available to the global (or the caller) scope.
An instance of a PowerShell class can be returned as objects from functions within a module. This is shown in the following example:
New-Module ModuleWithClass {
class ModuleClass {
[string] $Property = 'value'
}
function Get-Something {
[ModuleClass]::new()
}
} | Import-Module
Running the Get-Something
command will show that it returns an instance of the class:
PS> Get-Something
Property
--------
value
However, an instance of the class cannot be created in the global scope by default:
PS> [ModuleClass]::new()
InvalidOperation: Unable to find type [ModuleClass].
The using
module is required to directly work with a class inside a module. Unfortunately, this is not possible with a module created by New-Module
. The content below should be saved into a ModuleWithClass.psm1
file so it can be imported:
@'
class ModuleClass {
[string] $Property = 'value'
}
function Get-Something {
[ModuleClass]::new()
}
'@ | Set-Content -Path ModuleWithClass.psm1
Once the file is created, the module can be imported with a using module
statement.
using module .ModuleWithClass.psm1
The using module
statement can import either a module using a path or a module by name. Once the module has been imported this way, you can use the class:
PS> [ModuleClass]::new()
Property
--------
value
It is possible to work around this limitation by moving class definitions into ScriptsToProcess
. This is explored in the next section.
The content of the root module executes every time a module is imported. You can use a root module file to perform initialization steps, for example, initializing a cache, importing static data, or setting a default configuration.
These steps are extra code that you must add to the root module at the beginning or end of the file.
In some cases, it is desirable to execute actions before a module is imported or when a module is removed.
You can use the ScriptsToProcess
property in a module manifest to execute code in the caller scope before a module is imported.
You can use the ScriptsToProcess
property in the module manifest to run scripts in the caller scope before importing the module. The caller is the entity importing the module; if that is an end user in the global scope, then ScriptsToProcess
is executed in the global scope. If the module is imported by another script, ScriptsToProcess
is executed in that script scope.
There are no limitations on what actions can be performed in ScriptsToProcess
, but it is important to remember that it affects the global scope. Using the Remove-Module
command will not remove content added to the caller scope.
The ScriptsToProcess
property can be used, or abused, to make classes available to a module user without requiring the use of the using
module.
Since ScriptsToProcess
acts in the caller scope, this is not always ideal. The following snippet sets up a module with a class in a script that is executed in the caller scope to demonstrate this limitation:
@'
class ModuleClass {
[string] $Property = 'value'
}
'@ | Set-Content -Path 'Script.ps1'
@'
function Get-Something {
[ModuleClass]::new()
}
'@ | Set-Content -Path Module.psm1
$manifestParams = @{
Path = 'Module.psd1'
RootModule = 'Module.psm1'
ScriptsToProcess = 'Script.ps1'
}
New-ModuleManifest @manifestParams
You can import this module in the current scope to use the class:
PS> Import-Module -Name .Module.psd1
PS> [ModuleClass]::new()
Property
--------
value
PowerShell must be restarted to show the change in behavior when running the same command inside a function:
function Import-Something {
Import-Module -Name .Module.psd1 -Scope Global
}
Running Import-Something
will, as the command says, import the module in the global scope. However, ScriptsToProcess
still executes in the caller scope, that is, the scope of the function. Therefore, the class, exposed only via ScriptsToProcess
, is still not available in the global scope, as the following shows:
PS> Import-Something
PS> Get-Module Module
ModuleType Version PreRelease Name ExportedCommands
---------- ------- ---------- ---- ----------------
Script 0.0.1 Module Get-Something
PS> [ModuleClass]::new()
InvalidOperation: Unable to find type [ModuleClass].
ScriptsToProcess
can be useful in the right circumstances, but it is not a fix for every problem with module content.
Remove-Module
will attempt to remove module content from the session. If explicit actions are required when a module is removed, the OnRemove
event can be used.
The OnRemove
event is raised when you use Remove-Module
. Any script block assigned to the event as a handler will be invoked. The event handler may be used to trigger a cleanup of the artifacts created by the module (if required).
The ModuleInfo
object provides access to the OnRemove
event handler via the $executionContext
variable when it is used in the psm1
file:
$executionContext.SessionState.Module.OnRemove
The following module creates a file named log.txt
when the module is imported. An OnRemove
handler is added to the module to attempt to release a lock created by the StreamWriter
on the file when the module is removed:
@'
using namespace System.IO
$path = Join-Path $PSScriptRoot -ChildPath 'OnRemove.log'
$stream = [StreamWriter][File]::OpenWrite($path)
$stream.WriteLine('Initialising module')
$executionContext.SessionState.Module.OnRemove = {
$stream.WriteLine('Closing log')
$stream.Flush()
$stream.Close()
}
'@ | Set-Content OnRemove.psm1
The file is created when the module is imported, and a StreamWriter
is created:
PS> Import-Module -Name .OnRemove.psm1
PS> Get-ChildItem -Path OnRemove.log
Directory: C:Workspace
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a--- 17/04/2021 10:19 0 OnRemove.log
Commands such as Get-Content
cannot read the file; it is locked by the StreamWriter
.
PS> Get-Content .OnRemove.log
Get-Content: The process cannot access the file 'C:WorkspaceOnRemove.log' because it is being used by another process.
Running Remove-Module
triggers the OnRemove
event. The OnRemove
event writes one final line, flushes the buffer (StreamWriter
does not immediately write to the file), and closes the stream.
Once the stream is closed, Get-Content
is able to read the file content:
PS> Get-Content -Path OnRemove.log
Initialising module
Closing log
This event only runs when Remove-Module
is used; it does not trigger if the PowerShell console is closed. In this case, the only difference is that the Closing log
line is not written.
Modules are an important part of PowerShell as they encapsulate related functions (and other content). You can publish modules to public repositories such as the PowerShell Gallery, or any internal PowerShell repositories that may be in use.
Module layout in PowerShell is flexible beyond the creation of a psm1
file (or a dll
file in the case of a binary module).
You can optionally include a psd1
file to describe information about the module, including versions, what should be made available to anyone importing the module, and other metadata such as project information.
Multi-file module layouts are common as they allow authors to manage content in separate files rather than a single monolithic root module. Some module authors choose to merge content into a single file as part of a publishing process, while others are happy to load individual files via the root module.
You can use module scope to store the information a module needs to work; for example, authentication tokens used with a remote service.
You can use PowerShell classes within a module and return instances of classes via functions to users of a module. Directly using classes within a module requires the use of the using module
statement.
You can perform actions before importing a module via the ScriptsToProcess
property in a module manifest and when removing a module using Remove-Module
via the OnRemove
event.
In the next chapter, Chapter 21, Testing, you'll explore static analysis and unit testing in PowerShell.