From f3ae02f38b75764d09791fedc8eef20226192ac6 Mon Sep 17 00:00:00 2001 From: Justin Grote Date: Sat, 12 Apr 2025 10:31:48 -0700 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20Exclude=20Prereleases=20from=20?= =?UTF-8?q?Exclusive=20Matching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ModuleFast.psm1 | 72 ++++++++++++++++++++++++++++++++++++-------- ModuleFast.tests.ps1 | 20 ++++++++++-- README.MD | 2 ++ 3 files changed, 80 insertions(+), 14 deletions(-) diff --git a/ModuleFast.psm1 b/ModuleFast.psm1 index 023608e..d801e8f 100644 --- a/ModuleFast.psm1 +++ b/ModuleFast.psm1 @@ -250,7 +250,9 @@ function Install-ModuleFast { #Setting this to "CurrentUser" is the same as specifying the destination as 'Current'. This is a usability convenience. [InstallScope]$Scope, #The timeout for HTTP requests. This is set to 30 seconds by default. This is generally sufficient for most requests, but you may need to increase this if you are on a slow connection or are downloading large modules. - [int]$Timeout = 30 + [int]$Timeout = 30, + #ModuleFast performs some friendly operations that aren't strictly SemVer compliant. For example, if you ask for Module!<3.0.0, technically 3.0.0-alpha should be returned via the SemVer spec, but typically that's not what people actually want, they want what would effectively be Module!<2.999.999, so we exclude these prereleases for good UX. Specify this switch to enforce strict SemVer behavior and return the prerelease in this scenario. + [switch]$StrictSemVer ) begin { trap {$PSCmdlet.ThrowTerminatingError($PSItem)} @@ -395,7 +397,18 @@ function Install-ModuleFast { $ModulesToInstall.ToArray() } else { Write-Progress -Id 1 -Activity 'Install-ModuleFast' -Status 'Plan' -PercentComplete 1 - Get-ModuleFastPlan -Specification $ModulesToInstall -HttpClient $httpClient -Source $Source -Update:$Update -PreRelease:$Prerelease.IsPresent -DestinationOnly:$DestinationOnly -Destination $Destination -Timeout $Timeout + $getPlanParams = @{ + Specification = $ModulesToInstall + HttpClient = $httpClient + Source = $Source + Update = $Update + PreRelease = $Prerelease.IsPresent + DestinationOnly = $DestinationOnly + Destination = $Destination + Timeout = $Timeout + StrictSemVer = $StrictSemVer + } + Get-ModuleFastPlan @getPlanParams } } @@ -520,7 +533,8 @@ function Get-ModuleFastPlan { [int]$ParentProgress, [string]$Destination, [switch]$DestinationOnly, - [CancellationToken]$CancellationToken + [CancellationToken]$CancellationToken, + [switch]$StrictSemVer ) BEGIN { @@ -669,7 +683,7 @@ function Get-ModuleFastPlan { continue } - if ($currentModuleSpec.SatisfiedBy($candidate)) { + if ($currentModuleSpec.SatisfiedBy($candidate, $StrictSemVer)) { Write-Debug "${ModuleSpec}: Found satisfying version $candidate in the inlined index." $matchingEntry = $entries | Where-Object version -EQ $candidate if ($matchingEntry.count -gt 1) { throw 'Multiple matching Entries found for a specific version. This is a bug and should not happen' } @@ -729,7 +743,7 @@ function Get-ModuleFastPlan { continue } - if ($currentModuleSpec.SatisfiedBy($candidate)) { + if ($currentModuleSpec.SatisfiedBy($candidate, $StrictSemVer)) { Write-Debug "${currentModuleSpec}: Found satisfying version $candidate in the additional pages." $matchingEntry = $entries | Where-Object version -EQ $candidate if (-not $matchingEntry) { throw 'Multiple matching Entries found for a specific version. This is a bug and should not happen' } @@ -816,7 +830,7 @@ function Get-ModuleFastPlan { | Where-Object Name -EQ $dependency.Name | Sort-Object ModuleVersion -Descending | ForEach-Object { - if ($dependency.SatisfiedBy($PSItem.ModuleVersion)) { + if ($dependency.SatisfiedBy($PSItem.ModuleVersion, $StrictSemVer)) { Write-Debug "Dependency $dependency satisfied by existing planned install item $PSItem" return $false } @@ -1309,6 +1323,7 @@ function Add-Getters ([Parameter(Mandatory, ValueFromPipeline)][Type]$Type) { } #Information about a module, whether local or remote +[NoRunspaceAffinity()] class ModuleFastInfo: IComparable { [string]$Name #Sometimes the module version is not the same as the folder version, such as in the case of prerelease versions @@ -1387,6 +1402,8 @@ $ModuleFastInfoTypeData = @{ Update-TypeData -TypeName ModuleFastInfo @ModuleFastInfoTypeData -Force Update-TypeData -TypeName Nuget.Versioning.NugetVersion -SerializationMethod String -Force + +[NoRunspaceAffinity()] class ModuleFastSpec { #These properties are effectively read only thanks to some getter wizardy @@ -1551,15 +1568,46 @@ class ModuleFastSpec { } #region Methods + [bool] SatisfiedBy([version]$Version) { - return $this.SatisfiedBy([NuGetVersion]::new($Version)) + return $this.SatisfiedBy([NuGetVersion]::new($Version, $false)) + } + [bool] SatisfiedBy([version]$Version, [bool]$strictSemVer) { + return $this.SatisfiedBy([NuGetVersion]::new($Version, $strictSemVer)) } [bool] SatisfiedBy([NugetVersion]$Version) { - if ($this._VersionRange.IsFloating) { - return $this._VersionRange.Float.Satisfies($Version) + return $this.SatisfiedBy($Version, $false) + } + + #strictSemVer means [1.0.0,2.0.0) will match 2.0.0-alpha1. Most people don't want this. + [bool] SatisfiedBy([NugetVersion]$Version, [bool]$strictSemVer) { + $range = $this._VersionRange + $strictSatisfies = $range.IsFloating ? + $range.Float.Satisfies($Version) : + $range.Satisfies($Version) + + if ($strictSemVer) { + return $strictSatisfies } - return $this._VersionRange.Satisfies($Version) + + if (-not $range.MaxVersion) {return $strictSatisfies} + $max = $range.MaxVersion + + if ( + #Example: Version is 2.0.0-alpha1 and the spec is module:[1.0.0,2.0.0) + $Version.IsPrerelease -and + -not $range.IsMaxInclusive -and + ($max.Major -eq $Version.Major) -and + ($max.Minor -eq $Version.Minor) -and + ($max.Patch -eq $Version.Patch) + ) + { + Write-Verbose "ModuleFastSpec: $this is being compared to $Version. We are excluding this prerelease for ease of use as it is assumed you did not want prereleases when specifying a major-exclusive release. Specify -StrictSemVer to override this behavior." + return $false + } + + return $strictSatisfies } [bool] Overlap([ModuleFastSpec]$other) { @@ -1568,7 +1616,7 @@ class ModuleFastSpec { [bool] Overlap([VersionRange]$other) { [List[VersionRange]]$ranges = @($this._VersionRange, $other) - $subset = [versionrange]::CommonSubset($ranges) + $subset = [VersionRange]::CommonSubset($ranges) #If the subset has an explicit version of 0.0.0, this means there was no overlap. return '(0.0.0, 0.0.0)' -ne $subset } @@ -1932,7 +1980,7 @@ function Find-LocalModule { } $candidateVersion = $manifestCandidate.ModuleVersion - if ($ModuleSpec.SatisfiedBy($candidateVersion)) { + if ($ModuleSpec.SatisfiedBy($candidateVersion, $StrictSemVer)) { if ($Update -and ($ModuleSpec.Max -ne $candidateVersion)) { Write-Debug "${ModuleSpec}: Skipping $candidateVersion because -Update was specified and the version does not exactly meet the upper bound of the spec or no upper bound was specified at all, meaning there is a possible newer version remotely." #We can use this ref later to find out if our best remote version matches what is installed without having to read the manifest again diff --git a/ModuleFast.tests.ps1 b/ModuleFast.tests.ps1 index 3471b51..3425aa0 100644 --- a/ModuleFast.tests.ps1 +++ b/ModuleFast.tests.ps1 @@ -252,10 +252,10 @@ Describe 'Get-ModuleFastPlan' -Tag 'E2E' { ModuleName = 'PrereleaseTest' }, @{ - Spec = 'PrereleaseTest!<0.0.1' + Spec = 'PrereleaseTest!<=0.0.1' Check = { $actual.Name | Should -Be 'PrereleaseTest' - $actual.ModuleVersion | Should -Be '0.0.1-prerelease' + $actual.ModuleVersion | Should -Be '0.0.1' } ModuleName = 'PrereleaseTest' }, @@ -278,6 +278,22 @@ Describe 'Get-ModuleFastPlan' -Tag 'E2E' { $actual.ModuleVersion | Should -Be '1.99.0' #Nuget changes this to 1.99 } }, + # Special cases where the upper bound is specified as exclusive, ignore prereleases + @{ + Spec = 'PrereleaseTest!<0.0.2' + ModuleName = 'PrereleaseTest' + Check = { + $actual.ModuleVersion | Should -Be '0.0.1' #Install module version is 0.4.15.0 but nuget truncates this + } + }, + @{ + Spec = 'PrereleaseTest!<=0.0.2' + ModuleName = 'PrereleaseTest' + Check = { + $actual.ModuleVersion | Should -Be '0.0.2-prerelease' #Install module version is 0.4.15.0 but nuget truncates this + } + }, + #End special cases @{ Spec = 'PnP.PowerShell:2.2.*' ModuleName = 'PnP.PowerShell' diff --git a/README.MD b/README.MD index 91e0d9f..9654a9d 100644 --- a/README.MD +++ b/README.MD @@ -89,6 +89,8 @@ For more information about NuGet version range syntax used with the ':' operator ModuleFast also fully supports the [ModuleSpecification object and hashtable-like string syntaxes](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_requires?view=powershell-7.5#-modules-module-name--hashtable) that are used by Install-Module and Install-PSResource. More information on this format: https://learn.microsoft.com/en-us/dotnet/api/microsoft.powershell.commands.modulespecification?view=powershellsdk-7.4.0 +NOTE: ModuleFast does not strictly conform to SemVer without the `-StrictSemVer` parameter. For example, for ergonomics, we exclude 2.0 prereleases from `Module<2.0`, since most people who do this do not want 2.0 prereleases which might contain breaking changes, even though by semver definition, `Module 2.0-alpha1` is less than 2.0 + ## Logging ModuleFast has extensive Verbose and Debug information available if you specify the -Verbose and/or -Debug parameters. This can be useful for troubleshooting or understanding how ModuleFast is working. Verbose level provides a high level "what" view of the process of module selection, while Debug level provides a much more detailed "Why" information about the module selection and installation process that can be useful in troubleshooting issues. From 687efcab3d1d9ea8173582fc7e1e65e0ea205137 Mon Sep 17 00:00:00 2001 From: Justin Grote Date: Sat, 12 Apr 2025 10:41:42 -0700 Subject: [PATCH 2/5] Adjust message, as exclusive is not just on Major only. --- ModuleFast.psm1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ModuleFast.psm1 b/ModuleFast.psm1 index d801e8f..ced6f53 100644 --- a/ModuleFast.psm1 +++ b/ModuleFast.psm1 @@ -1603,7 +1603,7 @@ class ModuleFastSpec { ($max.Patch -eq $Version.Patch) ) { - Write-Verbose "ModuleFastSpec: $this is being compared to $Version. We are excluding this prerelease for ease of use as it is assumed you did not want prereleases when specifying a major-exclusive release. Specify -StrictSemVer to override this behavior." + Write-Verbose "ModuleFastSpec: $this is being compared to $Version. We are excluding this prerelease for ease of use as it is assumed you did not want prereleases when specifying a max exclusive bound. Specify -StrictSemVer to override this behavior." return $false } From 9d6d23e8d615993850e1322aec4017bb1781bb8e Mon Sep 17 00:00:00 2001 From: Justin Grote Date: Sat, 12 Apr 2025 10:42:07 -0700 Subject: [PATCH 3/5] Update message --- ModuleFast.psm1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ModuleFast.psm1 b/ModuleFast.psm1 index ced6f53..847fe59 100644 --- a/ModuleFast.psm1 +++ b/ModuleFast.psm1 @@ -1603,7 +1603,7 @@ class ModuleFastSpec { ($max.Patch -eq $Version.Patch) ) { - Write-Verbose "ModuleFastSpec: $this is being compared to $Version. We are excluding this prerelease for ease of use as it is assumed you did not want prereleases when specifying a max exclusive bound. Specify -StrictSemVer to override this behavior." + Write-Verbose "ModuleFastSpec: $this is being compared to $Version. We are excluding this prerelease for ease of use as it is assumed you did not want prereleases when specifying an exclusive upper bound. Specify -StrictSemVer to override this behavior." return $false } From c03faeece6a5b9341bd090f9def093e9c3a59f65 Mon Sep 17 00:00:00 2001 From: Justin Grote Date: Mon, 21 Apr 2025 17:37:57 -0700 Subject: [PATCH 4/5] Fixup some release logic and add more tests including StrictSemVer --- ModuleFast.psm1 | 16 ++++++++++ ModuleFast.tests.ps1 | 73 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 79 insertions(+), 10 deletions(-) diff --git a/ModuleFast.psm1 b/ModuleFast.psm1 index 847fe59..117c174 100644 --- a/ModuleFast.psm1 +++ b/ModuleFast.psm1 @@ -1593,20 +1593,36 @@ class ModuleFastSpec { if (-not $range.MaxVersion) {return $strictSatisfies} $max = $range.MaxVersion + $min = $range.MinVersion if ( #Example: Version is 2.0.0-alpha1 and the spec is module:[1.0.0,2.0.0) $Version.IsPrerelease -and -not $range.IsMaxInclusive -and + -not $max.IsPrerelease -and ($max.Major -eq $Version.Major) -and ($max.Minor -eq $Version.Minor) -and ($max.Patch -eq $Version.Patch) + #If the minimum matches the maximum and has a prerelease, that means it's a range like (3.0.0-alpha,3.0.0-beta and we want strict matching) ) { + #In a special case like (3.0.0-alpha,3.0.0-beta) where the min and max are the same version, we want normal strict semver behavior + if ( + $min -and + $min.Major -eq $max.Major -and + $min.Minor -eq $max.Minor -and + $min.Patch -eq $max.Patch -and + $min.IsPrerelease + ) { + Write-Debug "ModuleFastSpec: $this is being compared to $Version. It was not excluded because the min matches the max and both are prereleases, so normal behavior occured." + return $strictSatisfies + } + Write-Verbose "ModuleFastSpec: $this is being compared to $Version. We are excluding this prerelease for ease of use as it is assumed you did not want prereleases when specifying an exclusive upper bound. Specify -StrictSemVer to override this behavior." return $false } + #Last resort is to use strict matching return $strictSatisfies } diff --git a/ModuleFast.tests.ps1 b/ModuleFast.tests.ps1 index 3425aa0..75e7ccc 100644 --- a/ModuleFast.tests.ps1 +++ b/ModuleFast.tests.ps1 @@ -159,6 +159,21 @@ Describe 'Get-ModuleFastPlan' -Tag 'E2E' { } -TestCases $moduleSpecTestCases } + Context 'StrictSemVer Parameter' { + It 'StrictSemVer matches prereleases with exclusive upper bound' { + $actual = Get-ModuleFastPlan 'PrereleaseTest!<0.0.2' -StrictSemVer + $actual | Should -HaveCount 1 + $actual.ModuleVersion.IsPrerelease | Should -Be $true + $actual.ModuleVersion.Patch | Should -Be 2 + } + It 'StrictSemVer not specified does not match prereleases with exclusive upper bound' { + $actual = Get-ModuleFastPlan 'PrereleaseTest!<0.0.2' + $actual | Should -HaveCount 1 + $actual.ModuleVersion.IsPrerelease | Should -Be $false + $actual.ModuleVersion.Patch | Should -Be 1 + } + } + Context 'ModuleFastSpec String' { $stringTestCases = ( @{ @@ -283,14 +298,43 @@ Describe 'Get-ModuleFastPlan' -Tag 'E2E' { Spec = 'PrereleaseTest!<0.0.2' ModuleName = 'PrereleaseTest' Check = { - $actual.ModuleVersion | Should -Be '0.0.1' #Install module version is 0.4.15.0 but nuget truncates this + $actual.ModuleVersion | Should -Be '0.0.1' } }, @{ Spec = 'PrereleaseTest!<=0.0.2' ModuleName = 'PrereleaseTest' Check = { - $actual.ModuleVersion | Should -Be '0.0.2-prerelease' #Install module version is 0.4.15.0 but nuget truncates this + $actual.ModuleVersion | Should -Be '0.0.2-prerelease' + } + }, + @{ + # Test lexical matching. This should not match the 'prerelease' version because r is after p + Spec = 'PrereleaseTest!:(0.0.2-rIsAfterP,0.0.2)' + ModuleName = 'PrereleaseTest' + Check = { + [string]$actual | Should -Match 'a matching module was not found' + } + }, + @{ + Spec = 'PrereleaseTest!<0.0.2-prerelease' + ModuleName = 'PrereleaseTest' + Check = { + [string]$actual.ModuleVersion | Should -Be '0.0.2-newerversion' + } + }, + @{ + Spec = 'PrereleaseTest!:(0.0.2-alpha,0.0.2)' + ModuleName = 'PrereleaseTest' + Check = { + [string]$actual.ModuleVersion | Should -Be '0.0.2-prerelease' + } + }, + @{ + Spec = 'PrereleaseTest!:(0.0.2-alpha,0.0.2-prerelease)' + ModuleName = 'PrereleaseTest' + Check = { + [string]$actual.ModuleVersion | Should -Be '0.0.2-newerversion' } }, #End special cases @@ -316,18 +360,27 @@ Describe 'Get-ModuleFastPlan' -Tag 'E2E' { } It 'Gets Module with String Parameter: ' { - $actual = Get-ModuleFastPlan $Spec - $actual | Should -HaveCount 1 - $ModuleName | Should -Be $actual.Name - $actual.ModuleVersion | Should -Not -BeNullOrEmpty + try { + $actual = Get-ModuleFastPlan $Spec + $actual | Should -HaveCount 1 + $ModuleName | Should -Be $actual.Name + $actual.ModuleVersion | Should -Not -BeNullOrEmpty + } catch { + $actual = $PSItem + } if ($Check) { . $Check } + } -TestCases $stringTestCases It 'Gets Module with String Pipeline: ' { - $actual = $Spec | Get-ModuleFastPlan - $actual | Should -HaveCount 1 - $ModuleName | Should -Be $actual.Name - $actual.ModuleVersion | Should -Not -BeNullOrEmpty + try { + $actual = $Spec | Get-ModuleFastPlan + $actual | Should -HaveCount 1 + $ModuleName | Should -Be $actual.Name + $actual.ModuleVersion | Should -Not -BeNullOrEmpty + } catch { + $actual = $PSItem + } if ($Check) { . $Check } } -TestCases $stringTestCases } From 2ba9fdfc84e1bce88b739020a9a4c603be8b7597 Mon Sep 17 00:00:00 2001 From: Justin Grote Date: Tue, 22 Apr 2025 08:46:33 -0700 Subject: [PATCH 5/5] Reword Version Match Message --- ModuleFast.psm1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ModuleFast.psm1 b/ModuleFast.psm1 index 117c174..51baf96 100644 --- a/ModuleFast.psm1 +++ b/ModuleFast.psm1 @@ -1618,7 +1618,7 @@ class ModuleFastSpec { return $strictSatisfies } - Write-Verbose "ModuleFastSpec: $this is being compared to $Version. We are excluding this prerelease for ease of use as it is assumed you did not want prereleases when specifying an exclusive upper bound. Specify -StrictSemVer to override this behavior." + Write-Verbose "ModuleFastSpec: $this is typically satisfied by $Version, but this prerelease of the exclusive maximum version specification was ignored for ease of use. Specify -StrictSemVer to allow pre-releases of excluded versions." return $false }