From 28bcb4edf930137766c7111a1dfd2d254b39ae7c Mon Sep 17 00:00:00 2001 From: Justin Grote Date: Tue, 19 Dec 2023 12:26:06 -0800 Subject: [PATCH 1/2] Add support for #requires in scripts --- ModuleFast.psm1 | 28 ++++++++++++++++--------- ModuleFast.tests.ps1 | 37 ++++++++++++++++++++++++++++++---- Test/Mocks/RequiresModule.psm1 | 1 + Test/Mocks/RequiresScript.ps1 | 1 + 4 files changed, 54 insertions(+), 13 deletions(-) create mode 100644 Test/Mocks/RequiresModule.psm1 create mode 100644 Test/Mocks/RequiresScript.ps1 diff --git a/ModuleFast.psm1 b/ModuleFast.psm1 index a335ee3..22a3444 100644 --- a/ModuleFast.psm1 +++ b/ModuleFast.psm1 @@ -1,6 +1,7 @@ #requires -version 7.2 using namespace Microsoft.PowerShell.Commands using namespace System.Management.Automation +using namespace System.Management.Automation.Language using namespace NuGet.Versioning using namespace System.Collections using namespace System.Collections.Concurrent @@ -51,7 +52,7 @@ function Install-ModuleFast { [AllowEmptyCollection()] [Parameter(Mandatory, Position = 0, ValueFromPipeline, ParameterSetName = 'Specification')][ModuleFastSpec[]]$Specification, - #Provide a required module specification path to install from. This can be a local psd1/json file, or a remote URL with a psd1/json file in supported manifest formats. + #Provide a required module specification path to install from. This can be a local psd1/json file, or a remote URL with a psd1/json file in supported manifest formats, or a .ps1/.psm1 file with a #Requires statement. [Parameter(Mandatory, ParameterSetName = 'Path')][string]$Path, #Where to install the modules. This defaults to the builtin module path on non-windows and a custom LOCALAPPDATA location on Windows. [string]$Destination, @@ -972,9 +973,11 @@ class ModuleFastSpec { #BUG: We cannot implement IEquatable directly because we need to self-reference ModuleFastSpec before it exists. #We can however just add Equals() method - - #Implementation of https://learn.microsoft.com/en-us/dotnet/api/system.iequatable-1.equals + [bool]Equals($other) { + return $this.GetHashCode() -eq $other.GetHashCode() + } + #end IEquatable [string] ToString() { $guid = $this._Guid -ne [Guid]::Empty ? " [$($this._Guid)]" : '' @@ -983,10 +986,6 @@ class ModuleFastSpec { [int] GetHashCode() { return $this.ToString().GetHashCode() } - [bool]Equals($other) { - return $this.GetHashCode() -eq $other.GetHashCode() - } - #end IEquatable #IComparable #Implementation of https://learn.microsoft.com/en-us/dotnet/api/system.icomparable-1.equals @@ -1378,10 +1377,19 @@ filter ConvertFrom-RequiredSpec { $extension = [Path]::GetExtension($resolvedPath) if ($extension -eq '.psd1') { Import-PowerShellDataFile -Path $resolvedPath + } elseif ($extension -in '.ps1', '.psm1') { + Write-Debug 'PowerShell Script/Module file detected, checking for #Requires' + $ast = [Parser]::ParseFile($resolvedPath, [ref]$null, [ref]$null) + [ModuleSpecification[]]$requiredModules = $ast.ScriptRequirements.RequiredModules + + if ($RequiredModules.count -eq 0) { + throw [NotSupportedException]'The script does not have a #Requires -Module statement so ModuleFast does not know what this module requires. See Get-Help about_requires for more.' + } + $requiredModules } elseif ($extension -in '.json', '.jsonc') { Get-Content -Path $resolvedPath -Raw | ConvertFrom-Json -Depth 5 } else { - throw [NotSupportedException]'Only .psd1 and .json files are supported to import to this command' + throw [NotSupportedException]'Only .ps1, psm1, .psd1, and .json files are supported to import to this command' } } } @@ -1401,7 +1409,9 @@ filter ConvertFrom-RequiredSpec { $requiredData = [string[]]$RequiredData } - if ($RequiredData -is [string[]]) { + if ($requiredData -is [ModuleSpecification[]] -or $requiredData -is [ModuleSpecification]) { + return $RequiredData + } elseif ($RequiredData -is [string[]]) { return [ModuleFastSpec[]]$RequiredData } elseif ($RequiredData -is [IDictionary]) { foreach ($kv in $RequiredData.GetEnumerator()) { diff --git a/ModuleFast.tests.ps1 b/ModuleFast.tests.ps1 index cceb35e..b19899e 100644 --- a/ModuleFast.tests.ps1 +++ b/ModuleFast.tests.ps1 @@ -505,19 +505,48 @@ Describe 'Install-ModuleFast' -Tag 'E2E' { It 'Installs from SpecFile' { $SCRIPT:Mocks = Resolve-Path "$PSScriptRoot/Test/Mocks" $specFilePath = Join-Path $Mocks $File - Install-ModuleFast @imfParams -Path $specFilePath + $modulesToInstall = Install-ModuleFast @imfParams -Path $specFilePath -WhatIf + #TODO: Verify individual modules and versions + $modulesToInstall | Should -Not -BeNullOrEmpty } -TestCases @( @{ - Name = 'PowerShell Data File'; + Name = 'PowerShell Data File' File = 'ModuleFast.requires.psd1' }, @{ - Name = 'JSON'; + Name = 'JSON' File = 'ModuleFast.requires.json' }, @{ - Name = 'JSONArray'; + Name = 'JSONArray' File = 'ModuleFastArray.requires.json' + }, + @{ + Name = 'ScriptRequires' + File = 'RequiresScript.ps1' + }, + @{ + Name = 'ScriptModule' + File = 'RequiresModule.psm1' } ) + + It 'Fails for script if #Requires is not Present' { + $scriptPath = Join-Path $testDrive 'norequires.ps1' + { + 'There is no requires here!' + } | Out-File $scriptPath + + { Install-ModuleFast @imfParams -Path $scriptPath } + | Should -Throw 'The script does not have a #Requires*' + } + It 'Fails for module if #Requires is not Present' { + $scriptPath = Join-Path $testDrive 'norequires.psm1' + { + 'There is no requires here!' + } | Out-File $scriptPath + + { Install-ModuleFast @imfParams -Path $scriptPath } + | Should -Throw 'The script does not have a #Requires*' + } } diff --git a/Test/Mocks/RequiresModule.psm1 b/Test/Mocks/RequiresModule.psm1 new file mode 100644 index 0000000..b3aad76 --- /dev/null +++ b/Test/Mocks/RequiresModule.psm1 @@ -0,0 +1 @@ +#requires -Module PreReleaseTest diff --git a/Test/Mocks/RequiresScript.ps1 b/Test/Mocks/RequiresScript.ps1 new file mode 100644 index 0000000..b3aad76 --- /dev/null +++ b/Test/Mocks/RequiresScript.ps1 @@ -0,0 +1 @@ +#requires -Module PreReleaseTest From eae0d9ae3fa319403d318d1914702540d459c54a Mon Sep 17 00:00:00 2001 From: Justin Grote Date: Tue, 19 Dec 2023 16:38:11 -0800 Subject: [PATCH 2/2] Add more specfile tests and rework logic to be less cumbersome --- ModuleFast.psm1 | 134 +++++++++++++++++++++++++++---------------- ModuleFast.tests.ps1 | 23 ++++++++ 2 files changed, 106 insertions(+), 51 deletions(-) diff --git a/ModuleFast.psm1 b/ModuleFast.psm1 index 22a3444..a691dc3 100644 --- a/ModuleFast.psm1 +++ b/ModuleFast.psm1 @@ -1354,67 +1354,38 @@ filter ConvertFrom-RequiredSpec { [OutputType([ModuleFastSpec[]])] param( [Parameter(Mandatory, ParameterSetName = 'File')][string]$RequiredSpecPath, - [Parameter(Mandatory, ParameterSetName = 'Object')][object]$RequiredSpec + [Parameter(Mandatory, ParameterSetName = 'Object')]$RequiredSpec ) $ErrorActionPreference = 'Stop' - #Merge Required Data into spec path + #If a spec path was specified, resolve it into RequiredSpec if ($RequiredSpecPath) { - $uri = $RequiredSpecPath -as [Uri] - - $RequiredData = if ($uri.scheme -in 'http', 'https') { - [string]$content = (Invoke-WebRequest -Uri $uri).Content - if ($content.StartsWith('@{')) { - $tempFile = [io.path]::GetTempFileName() - $content > $tempFile - Import-PowerShellDataFile -Path $tempFile - } else { - ConvertFrom-Json $content -Depth 5 - } - } else { - #Assume this is a local if a URL above didn't match - $resolvedPath = Resolve-Path $RequiredSpecPath - $extension = [Path]::GetExtension($resolvedPath) - if ($extension -eq '.psd1') { - Import-PowerShellDataFile -Path $resolvedPath - } elseif ($extension -in '.ps1', '.psm1') { - Write-Debug 'PowerShell Script/Module file detected, checking for #Requires' - $ast = [Parser]::ParseFile($resolvedPath, [ref]$null, [ref]$null) - [ModuleSpecification[]]$requiredModules = $ast.ScriptRequirements.RequiredModules - - if ($RequiredModules.count -eq 0) { - throw [NotSupportedException]'The script does not have a #Requires -Module statement so ModuleFast does not know what this module requires. See Get-Help about_requires for more.' - } - $requiredModules - } elseif ($extension -in '.json', '.jsonc') { - Get-Content -Path $resolvedPath -Raw | ConvertFrom-Json -Depth 5 - } else { - throw [NotSupportedException]'Only .ps1, psm1, .psd1, and .json files are supported to import to this command' - } - } + $specFromUri = Read-RequiredSpecFile $RequiredSpecPath + $RequiredSpec = $specFromUri } - if ($RequiredData -is [PSCustomObject] -and $RequiredData.psobject.baseobject -isnot [IDictionary]) { + $PassThruTypes = [string], + [string[]], + [ModuleFastSpec], + [ModuleFastSpec[]], + [ModuleSpecification[]], + [ModuleSpecification] + + if ($RequiredSpec.GetType() -in $PassThruTypes) { return [ModuleFastSpec[]]$requiredSpec } + + if ($RequiredSpec -is [PSCustomObject] -and $RequiredSpec.psobject.baseobject -isnot [IDictionary]) { Write-Debug 'PSCustomObject-based Spec detected, converting to hashtable' $requireHT = @{} - $RequiredData.psobject.Properties - | ForEach-Object { + $RequiredSpec.psobject.Properties + | ForEach-Object { $requireHT.Add($_.Name, $_.Value) } - $RequiredData = $requireHT + #Will be process by IDictionary case below + $RequiredSpec = $requireHT } - if ($RequiredData -is [Object[]] -and ($true -notin $RequiredData.GetEnumerator().Foreach{ $PSItem -isnot [string] })) { - Write-Debug 'RequiredData array detected and contains all string objects. Converting to string[]' - $requiredData = [string[]]$RequiredData - } - - if ($requiredData -is [ModuleSpecification[]] -or $requiredData -is [ModuleSpecification]) { - return $RequiredData - } elseif ($RequiredData -is [string[]]) { - return [ModuleFastSpec[]]$RequiredData - } elseif ($RequiredData -is [IDictionary]) { - foreach ($kv in $RequiredData.GetEnumerator()) { + if ($RequiredSpec -is [IDictionary]) { + foreach ($kv in $RequiredSpec.GetEnumerator()) { if ($kv.Value -is [IDictionary]) { throw [NotImplementedException]'TODO: PSResourceGet/PSDepend full syntax' } @@ -1433,9 +1404,70 @@ filter ConvertFrom-RequiredSpec { #All other potential options (<=, @, :, etc.) are a direct merge [ModuleFastSpec]"$($kv.Name)$($kv.Value)" } - } else { - throw [NotImplementedException]'TODO: Support simple array based json strings' + return } + + if ($RequiredSpec -is [Object[]] -and ($true -notin $RequiredSpec.GetEnumerator().Foreach{ $PSItem -isnot [string] })) { + Write-Debug 'RequiredData array detected and contains all string objects. Converting to string[]' + $RequiredSpec = [string[]]$RequiredSpec + } + + throw [InvalidDataException]'Could not evaluate the Required Specification to a known format.' +} + +function Read-RequiredSpecFile ($RequiredSpecPath) { + if ($uri.scheme -in 'http', 'https') { + [string]$content = (Invoke-WebRequest -Uri $uri).Content + if ($content.StartsWith('@{')) { + $tempFile = [io.path]::GetTempFileName() + $content > $tempFile + return Import-PowerShellDataFile -Path $tempFile + } else { + $json = ConvertFrom-Json $content -Depth 5 + return $json + } + } + + #Assume this is a local if a URL above didn't match + $resolvedPath = Resolve-Path $RequiredSpecPath + $extension = [Path]::GetExtension($resolvedPath) + + if ($extension -eq '.psd1') { + $manifestData = Import-PowerShellDataFile -Path $resolvedPath + if ($manifestData.ModuleVersion) { + [ModuleSpecification[]]$requiredModules = $manifestData.RequiredModules + Write-Debug 'Detected a Module Manifest, evaluating RequiredModules' + if ($requiredModules.count -eq 0) { + throw [InvalidDataException]'The manifest does not have a RequiredModules key so ModuleFast does not know what this module requires. See Get-Help about_module_manifests for more.' + } + return , $requiredModules + } else { + Write-Debug 'Did not detect a module manifest, passing through as-is' + return $manifestData + } + } + + if ($extension -in '.ps1', '.psm1') { + Write-Debug 'PowerShell Script/Module file detected, checking for #Requires' + $ast = [Parser]::ParseFile($resolvedPath, [ref]$null, [ref]$null) + [ModuleSpecification[]]$requiredModules = $ast.ScriptRequirements.RequiredModules + + if ($RequiredModules.count -eq 0) { + throw [NotSupportedException]'The script does not have a #Requires -Module statement so ModuleFast does not know what this module requires. See Get-Help about_requires for more.' + } + return , $requiredModules + } + + if ($extension -in '.json', '.jsonc') { + $json = Get-Content -Path $resolvedPath -Raw | ConvertFrom-Json -Depth 5 + if ($json -is [Object[]] -and $false -notin $json.getenumerator().foreach{ $_ -is [string] }) { + Write-Debug 'Detected a JSON array of strings, converting to string[]' + return , [string[]]$json + } + return $json + } + + throw [NotSupportedException]'Only .ps1, psm1, .psd1, and .json files are supported to import to this command' } filter Resolve-FolderVersion([NuGetVersion]$version) { diff --git a/ModuleFast.tests.ps1 b/ModuleFast.tests.ps1 index b19899e..baa9e05 100644 --- a/ModuleFast.tests.ps1 +++ b/ModuleFast.tests.ps1 @@ -549,4 +549,27 @@ Describe 'Install-ModuleFast' -Tag 'E2E' { { Install-ModuleFast @imfParams -Path $scriptPath } | Should -Throw 'The script does not have a #Requires*' } + It 'Fails if Module Manifest and RequiredModules is missing' { + $scriptPath = Join-Path $testDrive 'testmanifestnorequires.psd1' + "@{ + 'ModuleVersion' = '1.0.0' + }" | Out-File $scriptPath + { Install-ModuleFast @imfParams -Path $scriptPath } + | Should -Throw 'The manifest does not have a RequiredMOdules key*' + } + It 'Resolves Module Manifest RequiredModules' { + $scriptPath = Join-Path $testDrive 'testmanifest.psd1' + "@{ + 'ModuleVersion' = '1.0.0' + 'RequiredModules' = @( + 'PreReleaseTest' + @{ + ModuleName = 'Az.Accounts' + ModuleVersion = '2.7.0' + } + ) + }" | Out-File $scriptPath + $modules = Install-ModuleFast @imfParams -Path $scriptPath -WhatIf + $modules.count | Should -Be 2 + } }