Skip to content

Instantly share code, notes, and snippets.

@Jaykul
Last active June 11, 2018 07:11
Show Gist options
  • Save Jaykul/176c4aacc477a69b3d0fa86b4229503b to your computer and use it in GitHub Desktop.
Save Jaykul/176c4aacc477a69b3d0fa86b4229503b to your computer and use it in GitHub Desktop.
How we "compile" modules from source .ps1 files
# This is just an example of a Build.psd1
# The idea is simple: you can set values for any of the parameters of Optimize-Module in this hashtable:
@{
# If I make a build.psd1, I always specify the path to my module's psd1
Path = "YourModule.psd1"
# Copy assemblies you keep in a \lib sub-folder
CopyDirectories = "lib"
# Make sure we export aliases from this module
ExportModuleMember = "Export-ModuleMember -Function *-* -Alias *"
}
function Convert-CodeCoverage {
<#
.SYNOPSIS
Convert the file name and line numbers from Pester code coverage of "optimized" modules to the source
.EXAMPLE
Invoke-Pester .\Tests -CodeCoverage (Get-ChildItem .\Output -Filter *.psm1).FullName -PassThru |
Convert-CodeCoverage -SourceRoot .\Source -Relative
Runs pester tests from a "Tests" subfolder against an optimized module in the "Output" folder,
piping the results through Convert-CodeCoverage to render the code coverage misses with the source paths.
#>
param(
# The root of the source folder (for resolving source code paths)
[Parameter(Mandatory)]
[string]$SourceRoot,
# The output of `Invoke-Pester -Pasthru`
# Note: Pester doesn't apply a custom type name
[Parameter(ValueFromPipeline)]
[PSObject]$InputObject,
# Output paths as short paths, relative to the SourceRoot
[switch]$Relative
)
begin {
$filemap = @{}
# Conditionally define the Resolve function as either Convert-Path or Resolve-Path
${function:Resolve} = if($Relative) {
{ process { $_ | Resolve-Path -Relative } }
} else {
{ process { $_ | Convert-Path } }
}
}
process {
Push-Location $SourceRoot
try {
foreach ($miss in $InputObject.CodeCoverage.MissedCommands ) {
if (!$filemap.ContainsKey($miss.File)) {
$matches = Select-String "# BEGIN (?<path>.*)" -Path $miss.file
$filemap[$miss.File] = @($matches.ForEach( {
[PSCustomObject]@{
Line = $_.LineNumber
Path = $_.Matches[0].Groups["path"].Value | Resolve
}
}))
}
$hit = $filemap[$miss.file]
# These are all negative, indicating they are the match *after* the line we're searching for
# We need the match *before* the line we're searching for
# And we need it as a zero-based index:
$index = -2 - [Array]::BinarySearch($filemap[$miss.file].Line, $miss.Line)
$Source = $filemap[$miss.file][$index]
$miss.File = $Source.Path
$miss.Line = $miss.Line - $Source.Line
$miss
}
} finally {
Pop-Location
}
}
}
#Requires -Module Configuration
function Optimize-Module {
<#
.Synopsis
Compile a module from ps1 files to a single psm1
.Description
Compiles modules from source according to conventions:
1. A single ModuleName.psd1 manifest file with metadata
2. Source subfolders in the same directory as the manifest:
Classes, Private, Public contain ps1 files
3. Optionally, a build.psd1 file containing settings for this function
The optimization process:
1. The OutputDirectory is created
2. All psd1/psm1/ps1xml files in the root will be copied to the output
3. If specified, $CopyDirectories will be copied to the output
4. The ModuleName.psm1 will be generated (overwritten completely) by concatenating all .ps1 files in subdirectories (that aren't specified in CopySubdirectories)
5. The ModuleName.psd1 will be updated (based on existing data)
#>
param(
# The path to the module folder, manifest or build.psd1
[Parameter(Position=0, ValueFromPipelineByPropertyName)]
[ValidateScript( {
if (($IsPath = Test-Path $_ )) {
$true
} else {
throw "Source must point to a valid module"
}
} )]
[Alias("ModuleManifest")]
[string]$Path,
# Where to build the module.
# Defaults to a version number folder, adjacent to the module folder
[Alias("Destination")]
[string]$OutputDirectory,
[version]$ModuleVersion,
# Folders which should be copied intact to the module output
# Can be relative to the module folder
[AllowEmptyCollection()]
[string[]]$CopyDirectories = @(),
# A Filter (relative to the module folder) for public functions
# If non-empty, ExportedFunctions will be set with the file BaseNames of matching files
# Defaults to Public\*.ps1
[AllowEmptyString()]
[string[]]$PublicFilter = "Public\*.ps1",
# File encoding for output RootModule (defaults to UTF8)
[Microsoft.PowerShell.Commands.FileSystemCmdletProviderEncoding]
$Encoding = "UTF8",
# A line which will be added at the bottom of the psm1. The intention is to allow you to add an export like:
# Export-ModuleMember -Alias *-QM* -Functions * -Variables QMConstant_*
#
# The default is nothing
$ExportModuleMember,
# Controls whether or not there is a build or cleanup performed
[ValidateSet("Clean", "Build", "CleanBuild")]
[string]$Target = "CleanBuild",
# Output the ModuleInfo of the "built" module
[switch]$Passthru
)
process {
# If a path is $passed, use that
if($Path) {
$ModuleBase = Split-Path $Path -Parent
# Do not use GetFileNameWithoutExtension, some module names have dots in them
$ModuleName = (Split-Path $Path -Leaf) -replace ".psd1$"
# Support passing the path to a module folder
if (Test-Path $Path -PathType Container) {
if ( (Test-Path (Join-Path $Path build.psd1)) -or
(Test-Path (Join-Path $Path "$ModuleName.psd1"))
) {
$ModuleBase = $Path
$Path = Join-Path $Path "$ModuleName.psd1"
if(Test-Path $Path) {
$PSBoundParameters["Path"] = $Path
} else {
$null = $PSBoundParameters.Remove("Path")
}
} else {
throw "Module not found in $Path. Try passing the full path to the manifest file."
}
}
# Add support for passing the path to a build.psd1
if( (Test-Path $Path -PathType Leaf) -and ($ModuleName -eq "build") ) {
$null = $PSBoundParameters.Remove("Path")
}
Push-Location $ModuleBase
# Otherwise, look for a local build.psd1
} elseif(Test-Path Build.psd1) {
Push-Location
} else {
throw "Build.psd1 not found in PWD. You must specify the $Path to the build"
}
# Read build.psd1 for defaults
if(Test-Path Build.psd1) {
$BuildInfo = Import-LocalizedData -BaseDirectory $Pwd.Path -FileName Build
} else {
$BuildInfo = @{}
}
# Overwrite with parameter values
foreach($property in $PSBoundParameters.Keys) {
$BuildInfo.$property = $PSBoundParameters.$property
}
# Read Module Manifest for details
$ModuleInfo = Test-ModuleManifest $BuildInfo.Path -WarningAction SilentlyContinue -ErrorAction SilentlyContinue -ErrorVariable Problems
if($Problems) {
$Problems = $Problems.Where{ $_.FullyQualifiedErrorId -notmatch "^Modules_InvalidRequiredModulesinModuleManifest"}
if($Problems) {
foreach($problem in $Problems) {
Write-Error $problem
}
throw "Unresolvable problems in module manifest"
}
}
foreach($property in $BuildInfo.Keys) {
# Note:we can't overwrite the Path from the Build.psd1
Add-Member -Input $ModuleInfo -Type NoteProperty -Name $property -Value $BuildInfo.$property -ErrorAction SilentlyContinue
}
# Copy in default parameters
if(!(Get-Member -InputObject $ModuleInfo -Name PublicFilter)){
Add-Member -Input $ModuleInfo -Type NoteProperty -Name PublicFilter -Value $PublicFilter
}
if(!(Get-Member -InputObject $ModuleInfo -Name Encoding)){
Add-Member -Input $ModuleInfo -Type NoteProperty -Name Encoding -Value $Encoding
}
# TODO: Increment version?
# Ensure OutputDirectory
if(!$ModuleInfo.OutputDirectory) {
$OutputDirectory = Join-Path (Split-Path $ModuleInfo.ModuleBase -Parent) $ModuleInfo.Version
Add-Member -Input $ModuleInfo -Type NoteProperty -Name OutputDirectory -Value $OutputDirectory -Force
}
$OutputDirectory = $ModuleInfo.OutputDirectory
Write-Progress "Building $($ModuleInfo.ModuleBase)" -Status "Use -Verbose for more information"
Write-Verbose "Building $($ModuleInfo.ModuleBase)"
Write-Verbose " Output to: $OutputDirectory"
if ($Target -match "Clean") {
Write-Verbose "Cleaning $OutputDirectory"
if (Test-Path $OutputDirectory) {
Remove-Item $OutputDirectory -Recurse -Force -ErrorAction Stop
}
if($Target -notmatch "Build") {
return # No build, just cleaning
}
} else {
# If we're not cleaning, skip the build if it's up to date already
Write-Verbose "Target $Target"
$NewestBuild = Get-ChildItem $OutputDirectory -Recurse |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1 -ExpandProperty LastWriteTime
$IsNew = Get-ChildItem $ModuleInfo.ModuleBase -Recurse |
Where-Object LastWriteTime -gt $NewestBuild |
Select-Object -First 1 -ExpandProperty LastWriteTime
if($null -eq $IsNew) {
return # Skip the build
}
}
$null = mkdir $OutputDirectory -Force
Write-Verbose "Copy files to $OutputDirectory"
# Copy the files and folders which won't be processed
Copy-Item *.psm1, *.psd1, *.ps1xml -Exclude "build.psd1" -Destination $OutputDirectory -Force
if($ModuleInfo.CopyDirectories) {
Write-Verbose "Copy Entire Directories: $($ModuleInfo.CopyDirectories)"
Copy-Item -Path $ModuleInfo.CopyDirectories -Recurse -Destination $OutputDirectory -Force
}
# Output psm1
$RootModule = Join-Path $OutputDirectory "$($ModuleInfo.Name).psm1"
$OutputManifest = Join-Path $OutputDirectory "$($ModuleInfo.Name).psd1"
Write-Verbose "Combine scripts to $RootModule"
# Prefer pipeline to speed for the sake of memory and file IO
# SilentlyContinue because there don't *HAVE* to be functions at all
$AllScripts = Get-ChildItem -Path $ModuleInfo.ModuleBase -Exclude $ModuleInfo.CopyDirectories -Directory -ErrorAction SilentlyContinue |
Get-ChildItem -Filter *.ps1 -Recurse -ErrorAction SilentlyContinue
if($AllScripts) {
$AllScripts | ForEach-Object {
$SourceName = Resolve-Path $_.FullName -Relative
Write-Verbose "Adding $SourceName"
"# BEGIN $SourceName"
Get-Content $SourceName
"# END $SourceName"
} | Set-Content -Path $RootModule -Encoding $ModuleInfo.Encoding
if($ModuleInfo.ExportModuleMember) {
Add-Content -Path $RootModule -Value $ModuleInfo.ExportModuleMember -Encoding $ModuleInfo.Encoding
}
# If there is a PublicFilter, update ExportedFunctions
if($ModuleInfo.PublicFilter) {
# SilentlyContinue because there don't *HAVE* to be public functions
if($PublicFunctions = Get-ChildItem $ModuleInfo.PublicFilter -Recurse -ErrorAction SilentlyContinue | Select-Object -ExpandProperty BaseName) {
# TODO: Remove the _Public hack
Update-Metadata -Path $OutputManifest -PropertyName FunctionsToExport -Value ($PublicFunctions -replace "_Public$")
}
}
}
Write-Verbose "Update Manifest to $OutputManifest"
Update-Metadata -Path $OutputManifest -PropertyName Copyright -Value ($ModuleInfo.Copyright -replace "20\d\d",(Get-Date).Year)
if($ModuleVersion) {
Update-Metadata -Path $OutputManifest -PropertyName ModuleVersion -Value $ModuleVersion
}
# This is mostly for testing ...
if($Passthru) {
Test-ModuleManifest $OutputManifest
}
Pop-Location
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment