Skip to content

✨ Exclude Prereleases from Exclusive Matching #108

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 76 additions & 12 deletions ModuleFast.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -520,7 +533,8 @@ function Get-ModuleFastPlan {
[int]$ParentProgress,
[string]$Destination,
[switch]$DestinationOnly,
[CancellationToken]$CancellationToken
[CancellationToken]$CancellationToken,
[switch]$StrictSemVer
)

BEGIN {
Expand Down Expand Up @@ -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' }
Expand Down Expand Up @@ -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' }
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -1551,15 +1568,62 @@ 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
}

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 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
}
return $this._VersionRange.Satisfies($Version)

#Last resort is to use strict matching
return $strictSatisfies
}

[bool] Overlap([ModuleFastSpec]$other) {
Expand All @@ -1568,7 +1632,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
}
Expand Down Expand Up @@ -1932,7 +1996,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
Expand Down
89 changes: 79 additions & 10 deletions ModuleFast.tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
@{
Expand Down Expand Up @@ -252,10 +267,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'
},
Expand All @@ -278,6 +293,51 @@ 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'
}
},
@{
Spec = 'PrereleaseTest!<=0.0.2'
ModuleName = 'PrereleaseTest'
Check = {
$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
@{
Spec = 'PnP.PowerShell:2.2.*'
ModuleName = 'PnP.PowerShell'
Expand All @@ -300,18 +360,27 @@ Describe 'Get-ModuleFastPlan' -Tag 'E2E' {
}

It 'Gets Module with String Parameter: <Spec>' {
$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: <Spec>' {
$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
}
Expand Down
2 changes: 2 additions & 0 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down