From a83ec13669af9f17a4f9a61753fc68539ed31d1b Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sat, 20 Jan 2024 21:58:08 +0100 Subject: [PATCH] `Get-LocalizationData`: Refactor command (#109) --- CHANGELOG.md | 11 + Resolve-Dependency.ps1 | 924 ++++++++++++++---- Resolve-Dependency.psd1 | 10 + build.ps1 | 37 +- source/Public/Get-LocalizedData.ps1 | 411 ++++---- tests/Unit/Public/Get-LocalizedData.Tests.ps1 | 91 ++ 6 files changed, 1111 insertions(+), 373 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47bf1b6..2fc79c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Updated the pipelines files for resolving dependencies. +- `Get-LocalizedData` + - Refactored to simplify execution and debugging. The command previously + used a steppable pipeline (proxies `Import-LocalizedData`), that was + removed since it was not possible to use the command in a pipeline. + It just made it more complex and harder to debug. There are more + debug messages added to hopefully simplify solving some hard to find + edge cases bugs. + ### Fixed - `Assert-BoundParameter` diff --git a/Resolve-Dependency.ps1 b/Resolve-Dependency.ps1 index 3848462..17cc98e 100644 --- a/Resolve-Dependency.ps1 +++ b/Resolve-Dependency.ps1 @@ -46,6 +46,21 @@ .PARAMETER WithYAML Not yet written. + .PARAMETER UseModuleFast + Specifies to use ModuleFast instead of PowerShellGet to resolve dependencies + faster. + + .PARAMETER ModuleFastBleedingEdge + Specifies to use ModuleFast code that is in the ModuleFast's main branch + in its GitHub repository. The parameter UseModuleFast must also be set to + true. + + .PARAMETER UsePSResourceGet + Specifies to use the new PSResourceGet module instead of the (now legacy) PowerShellGet module. + + .PARAMETER PSResourceGetVersion + String specifying the module version for PSResourceGet if the `UsePSResourceGet` switch is utilized. + .NOTES Load defaults for parameters values from Resolve-Dependency.psd1 if not provided as parameter. @@ -100,7 +115,35 @@ param [Parameter()] [System.Collections.Hashtable] - $RegisterGallery + $RegisterGallery, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $UseModuleFast, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $ModuleFastBleedingEdge, + + [Parameter()] + [System.String] + $ModuleFastVersion, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $UsePSResourceGet, + + [Parameter()] + [System.String] + $PSResourceGetVersion, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $UsePowerShellGetCompatibilityModule, + + [Parameter()] + [System.String] + $UsePowerShellGetCompatibilityModuleVersion ) try @@ -111,17 +154,6 @@ try { Import-Module -Name Microsoft.PowerShell.Utility -RequiredVersion '3.1.0.0' } - - <# - Making sure the imported PackageManagement module is not from PS7 module - path. The VSCode PS extension is changing the $env:PSModulePath and - prioritize the PS7 path. This is an issue with PowerShellGet because - it loads an old version if available (or fail to load latest). - #> - Get-Module -ListAvailable PackageManagement | - Where-Object -Property 'ModuleBase' -NotMatch 'powershell.7' | - Select-Object -First 1 | - Import-Module -Force } Write-Verbose -Message 'Importing Bootstrap default parameters from ''$PSScriptRoot/Resolve-Dependency.psd1''.' @@ -155,7 +187,7 @@ try $PSBoundParameters.Add($parameterName, $variableValue) - Set-Variable -Name $parameterName -value $variableValue -Force -ErrorAction 'SilentlyContinue' + Set-Variable -Name $parameterName -Value $variableValue -Force -ErrorAction 'SilentlyContinue' } catch { @@ -169,288 +201,810 @@ catch Write-Warning -Message "Error attempting to import Bootstrap's default parameters from '$resolveDependencyConfigPath': $($_.Exception.Message)." } -Write-Progress -Activity 'Bootstrap:' -PercentComplete 0 -CurrentOperation 'NuGet Bootstrap' +# Handle when both ModuleFast and PSResourceGet is configured or/and passed as parameter. +if ($UseModuleFast -and $UsePSResourceGet) +{ + Write-Information -MessageData 'Both ModuleFast and PSResourceGet is configured or/and passed as parameter.' -InformationAction 'Continue' -# TODO: This should handle the parameter $AllowOldPowerShellGetModule. -$powerShellGetModule = Import-Module -Name 'PowerShellGet' -MinimumVersion '2.0' -ErrorAction 'SilentlyContinue' -PassThru + if ($PSVersionTable.PSVersion -ge '7.2') + { + $UsePSResourceGet = $false -# Install the package provider if it is not available. -$nuGetProvider = Get-PackageProvider -Name 'NuGet' -ListAvailable | Select-Object -First 1 + Write-Information -MessageData 'PowerShell 7.2 or higher being used, prefer ModuleFast over PSResourceGet.' -InformationAction 'Continue' + } + else + { + $UseModuleFast = $false -if (-not $powerShellGetModule -and -not $nuGetProvider) -{ - $providerBootstrapParameters = @{ - Name = 'nuget' - Force = $true - ForceBootstrap = $true - ErrorAction = 'Stop' + Write-Information -MessageData 'Windows PowerShell or PowerShell <=7.1 is being used, prefer PSResourceGet since ModuleFast is not supported on this version of PowerShell.' -InformationAction 'Continue' } +} - switch ($PSBoundParameters.Keys) +# Only bootstrap ModuleFast if it is not already imported. +if ($UseModuleFast -and -not (Get-Module -Name 'ModuleFast')) +{ + try { - 'Proxy' - { - $providerBootstrapParameters.Add('Proxy', $Proxy) - } + $moduleFastBootstrapScriptBlockParameters = @{} - 'ProxyCredential' + if ($ModuleFastBleedingEdge) { - $providerBootstrapParameters.Add('ProxyCredential', $ProxyCredential) + Write-Information -MessageData 'ModuleFast is configured to use Bleeding Edge (directly from ModuleFast''s main branch).' -InformationAction 'Continue' + + $moduleFastBootstrapScriptBlockParameters.UseMain = $true } + elseif($ModuleFastVersion) + { + if ($ModuleFastVersion -notmatch 'v') + { + $ModuleFastVersion = 'v{0}' -f $ModuleFastVersion + } - 'Scope' + Write-Information -MessageData ('ModuleFast is configured to use version {0}.' -f $ModuleFastVersion) -InformationAction 'Continue' + + $moduleFastBootstrapScriptBlockParameters.Release = $ModuleFastVersion + } + else { - $providerBootstrapParameters.Add('Scope', $Scope) + Write-Information -MessageData 'ModuleFast is configured to use latest released version.' -InformationAction 'Continue' } - } - if ($AllowPrerelease) - { - $providerBootstrapParameters.Add('AllowPrerelease', $true) - } + $moduleFastBootstrapUri = 'bit.ly/modulefast' # cSpell: disable-line - Write-Information -MessageData 'Bootstrap: Installing NuGet Package Provider from the web (Make sure Microsoft addresses/ranges are allowed).' + Write-Debug -Message ('Using bootstrap script at {0}' -f $moduleFastBootstrapUri) - # TODO: This does not handle a private Gallery yet. - $null = Install-PackageProvider @providerBootstrapParams + $invokeWebRequestParameters = @{ + Uri = $moduleFastBootstrapUri + ErrorAction = 'Stop' + } - $nuGetProvider = Get-PackageProvider -Name 'NuGet' -ListAvailable | Select-Object -First 1 + $moduleFastBootstrapScript = Invoke-WebRequest @invokeWebRequestParameters - $nuGetProviderVersion = $nuGetProvider.Version.ToString() + $moduleFastBootstrapScriptBlock = [ScriptBlock]::Create($moduleFastBootstrapScript) - Write-Information -MessageData "Bootstrap: Importing NuGet Package Provider version $nuGetProviderVersion to current session." + & $moduleFastBootstrapScriptBlock @moduleFastBootstrapScriptBlockParameters + } + catch + { + Write-Warning -Message ('ModuleFast could not be bootstrapped. Reverting to PSResourceGet. Error: {0}' -f $_.Exception.Message) - $Null = Import-PackageProvider -Name 'NuGet' -RequiredVersion $nuGetProviderVersion -Force + $UseModuleFast = $false + $UsePSResourceGet = $true + } } -if ($RegisterGallery) +if ($UsePSResourceGet) { - if ($RegisterGallery.ContainsKey('Name') -and -not [System.String]::IsNullOrEmpty($RegisterGallery.Name)) + $psResourceGetModuleName = 'Microsoft.PowerShell.PSResourceGet' + + # If PSResourceGet was used prior it will be locked and we can't replace it. + if ((Test-Path -Path "$PSDependTarget/$psResourceGetModuleName" -PathType 'Container') -and (Get-Module -Name $psResourceGetModuleName)) { - $Gallery = $RegisterGallery.Name + Write-Information -MessageData ('{0} is already bootstrapped and imported into the session. If there is a need to refresh the module, open a new session and resolve dependencies again.' -f $psResourceGetModuleName) -InformationAction 'Continue' } else { - $RegisterGallery.Name = $Gallery - } - - Write-Progress -Activity 'Bootstrap:' -PercentComplete 7 -CurrentOperation "Verifying private package repository '$Gallery'" -Completed + Write-Debug -Message ('{0} do not exist, saving the module to RequiredModules.' -f $psResourceGetModuleName) - $previousRegisteredRepository = Get-PSRepository -Name $Gallery -ErrorAction 'SilentlyContinue' + $psResourceGetDownloaded = $false - if ($previousRegisteredRepository.SourceLocation -ne $RegisterGallery.SourceLocation) - { - if ($previousRegisteredRepository) + try { - Write-Progress -Activity 'Bootstrap:' -PercentComplete 9 -CurrentOperation "Re-registrering private package repository '$Gallery'" -Completed + if (-not $PSResourceGetVersion) + { + # Default to latest version if no version is passed in parameter or specified in configuration. + $psResourceGetUri = "https://www.powershellgallery.com/api/v2/package/$psResourceGetModuleName" + } + else + { + $psResourceGetUri = "https://www.powershellgallery.com/api/v2/package/$psResourceGetModuleName/$PSResourceGetVersion" + } + + $invokeWebRequestParameters = @{ + # TODO: Should support proxy parameters passed to the script. + Uri = $psResourceGetUri + OutFile = "$PSDependTarget/$psResourceGetModuleName.nupkg" # cSpell: ignore nupkg + ErrorAction = 'Stop' + } + + $previousProgressPreference = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' - Unregister-PSRepository -Name $Gallery + # Bootstrapping Microsoft.PowerShell.PSResourceGet. + Invoke-WebRequest @invokeWebRequestParameters - $unregisteredPreviousRepository = $true + $ProgressPreference = $previousProgressPreference + + $psResourceGetDownloaded = $true } - else + catch { - Write-Progress -Activity 'Bootstrap:' -PercentComplete 9 -CurrentOperation "Registering private package repository '$Gallery'" -Completed + Write-Warning -Message ('{0} could not be bootstrapped. Reverting to PowerShellGet. Error: {1}' -f $psResourceGetModuleName, $_.Exception.Message) } - Register-PSRepository @RegisterGallery + $UsePSResourceGet = $false + + if ($psResourceGetDownloaded) + { + # On Windows PowerShell the command Expand-Archive do not like .nupkg as a zip archive extension. + $zipFileName = ((Split-Path -Path $invokeWebRequestParameters.OutFile -Leaf) -replace 'nupkg', 'zip') + + $renameItemParameters = @{ + Path = $invokeWebRequestParameters.OutFile + NewName = $zipFileName + Force = $true + } + + Rename-Item @renameItemParameters + + $psResourceGetZipArchivePath = Join-Path -Path (Split-Path -Path $invokeWebRequestParameters.OutFile -Parent) -ChildPath $zipFileName + + $expandArchiveParameters = @{ + Path = $psResourceGetZipArchivePath + DestinationPath = "$PSDependTarget/$psResourceGetModuleName" + Force = $true + } + + Expand-Archive @expandArchiveParameters + + Remove-Item -Path $psResourceGetZipArchivePath + + Import-Module -Name $expandArchiveParameters.DestinationPath -Force + + # Successfully bootstrapped PSResourceGet, so let's use it. + $UsePSResourceGet = $true + } } -} -Write-Progress -Activity 'Bootstrap:' -PercentComplete 10 -CurrentOperation "Ensuring Gallery $Gallery is trusted" + if ($UsePSResourceGet) + { + $psResourceGetModule = Get-Module -Name $psResourceGetModuleName + + $psResourceGetModuleVersion = $psResourceGetModule.Version.ToString() -# Fail if the given PSGallery is not registered. -$previousGalleryInstallationPolicy = (Get-PSRepository -Name $Gallery -ErrorAction 'Stop').InstallationPolicy + if ($psResourceGetModule.PrivateData.PSData.Prerelease) + { + $psResourceGetModuleVersion += '-{0}' -f $psResourceGetModule.PrivateData.PSData.Prerelease + } -if ($previousGalleryInstallationPolicy -ne 'Trusted') -{ - # Only change policy if the repository is not trusted - Set-PSRepository -Name $Gallery -InstallationPolicy 'Trusted' -ErrorAction 'Ignore' + Write-Information -MessageData ('Using {0} v{1}.' -f $psResourceGetModuleName, $psResourceGetModuleVersion) -InformationAction 'Continue' + + if ($UsePowerShellGetCompatibilityModule) + { + $savePowerShellGetParameters = @{ + Name = 'PowerShellGet' + Path = $PSDependTarget + Repository = 'PSGallery' + TrustRepository = $true + } + + if ($UsePowerShellGetCompatibilityModuleVersion) + { + $savePowerShellGetParameters.Version = $UsePowerShellGetCompatibilityModuleVersion + + # Check if the version is a prerelease. + if ($UsePowerShellGetCompatibilityModuleVersion -match '\d+\.\d+\.\d+-.*') + { + $savePowerShellGetParameters.Prerelease = $true + } + } + + Save-PSResource @savePowerShellGetParameters + + Import-Module -Name "$PSDependTarget/PowerShellGet" + } + } } -try +# Check if legacy PowerShellGet and PSDepend must be bootstrapped. +if (-not ($UseModuleFast -or $UsePSResourceGet)) { - Write-Progress -Activity 'Bootstrap:' -PercentComplete 25 -CurrentOperation 'Checking PowerShellGet' + if ($PSVersionTable.PSVersion.Major -le 5) + { + <# + Making sure the imported PackageManagement module is not from PS7 module + path. The VSCode PS extension is changing the $env:PSModulePath and + prioritize the PS7 path. This is an issue with PowerShellGet because + it loads an old version if available (or fail to load latest). + #> + Get-Module -ListAvailable PackageManagement | + Where-Object -Property 'ModuleBase' -NotMatch 'powershell.7' | + Select-Object -First 1 | + Import-Module -Force + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 0 -CurrentOperation 'NuGet Bootstrap' + + $importModuleParameters = @{ + Name = 'PowerShellGet' + MinimumVersion = '2.0' + MaximumVersion = '2.8.999' + ErrorAction = 'SilentlyContinue' + PassThru = $true + } + + if ($AllowOldPowerShellGetModule) + { + $importModuleParameters.Remove('MinimumVersion') + } - # Ensure the module is loaded and retrieve the version you have. - $powerShellGetVersion = (Import-Module -Name 'PowerShellGet' -PassThru -ErrorAction 'SilentlyContinue').Version + $powerShellGetModule = Import-Module @importModuleParameters - Write-Verbose -Message "Bootstrap: The PowerShellGet version is $powerShellGetVersion" + # Install the package provider if it is not available. + $nuGetProvider = Get-PackageProvider -Name 'NuGet' -ListAvailable -ErrorAction 'SilentlyContinue' | + Select-Object -First 1 - # Versions below 2.0 are considered old, unreliable & not recommended - if (-not $powerShellGetVersion -or ($powerShellGetVersion -lt [System.Version] '2.0' -and -not $AllowOldPowerShellGetModule)) + if (-not $powerShellGetModule -and -not $nuGetProvider) { - Write-Progress -Activity 'Bootstrap:' -PercentComplete 40 -CurrentOperation 'Installing newer version of PowerShellGet' - - $installPowerShellGetParameters = @{ - Name = 'PowerShellGet' - Force = $True - SkipPublisherCheck = $true - AllowClobber = $true - Scope = $Scope - Repository = $Gallery + $providerBootstrapParameters = @{ + Name = 'NuGet' + Force = $true + ForceBootstrap = $true + ErrorAction = 'Stop' + Scope = $Scope } switch ($PSBoundParameters.Keys) { 'Proxy' { - $installPowerShellGetParameters.Add('Proxy', $Proxy) + $providerBootstrapParameters.Add('Proxy', $Proxy) } 'ProxyCredential' { - $installPowerShellGetParameters.Add('ProxyCredential', $ProxyCredential) + $providerBootstrapParameters.Add('ProxyCredential', $ProxyCredential) } - 'GalleryCredential' + 'AllowPrerelease' { - $installPowerShellGetParameters.Add('Credential', $GalleryCredential) + $providerBootstrapParameters.Add('AllowPrerelease', $AllowPrerelease) } } - Write-Progress -Activity 'Bootstrap:' -PercentComplete 60 -CurrentOperation 'Installing newer version of PowerShellGet' - - Install-Module @installPowerShellGetParameters + Write-Information -MessageData 'Bootstrap: Installing NuGet Package Provider from the web (Make sure Microsoft addresses/ranges are allowed).' - Remove-Module -Name 'PowerShellGet' -Force -ErrorAction 'SilentlyContinue' - Remove-Module -Name 'PackageManagement' -Force + $null = Install-PackageProvider @providerBootstrapParameters - $powerShellGetModule = Import-Module PowerShellGet -Force -PassThru + $nuGetProvider = Get-PackageProvider -Name 'NuGet' -ListAvailable | Select-Object -First 1 - $powerShellGetVersion = $powerShellGetModule.Version.ToString() + $nuGetProviderVersion = $nuGetProvider.Version.ToString() - Write-Information -MessageData "Bootstrap: PowerShellGet version loaded is $powerShellGetVersion" - } + Write-Information -MessageData "Bootstrap: Importing NuGet Package Provider version $nuGetProviderVersion to current session." - # Try to import the PSDepend module from the available modules. - $getModuleParameters = @{ - Name = 'PSDepend' - ListAvailable = $true + $Null = Import-PackageProvider -Name 'NuGet' -RequiredVersion $nuGetProviderVersion -Force } - $psDependModule = Get-Module @getModuleParameters - - if ($PSBoundParameters.ContainsKey('MinimumPSDependVersion')) + if ($RegisterGallery) { - try + if ($RegisterGallery.ContainsKey('Name') -and -not [System.String]::IsNullOrEmpty($RegisterGallery.Name)) { - $psDependModule = $psDependModule | Where-Object -FilterScript { $_.Version -ge $MinimumPSDependVersion } + $Gallery = $RegisterGallery.Name } - catch + else { - throw ('There was a problem finding the minimum version of PSDepend. Error: {0}' -f $_) + $RegisterGallery.Name = $Gallery } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 7 -CurrentOperation "Verifying private package repository '$Gallery'" -Completed + + $previousRegisteredRepository = Get-PSRepository -Name $Gallery -ErrorAction 'SilentlyContinue' + + if ($previousRegisteredRepository.SourceLocation -ne $RegisterGallery.SourceLocation) + { + if ($previousRegisteredRepository) + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 9 -CurrentOperation "Re-registrering private package repository '$Gallery'" -Completed + + Unregister-PSRepository -Name $Gallery + + $unregisteredPreviousRepository = $true + } + else + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 9 -CurrentOperation "Registering private package repository '$Gallery'" -Completed + } + + Register-PSRepository @RegisterGallery + } + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 10 -CurrentOperation "Ensuring Gallery $Gallery is trusted" + + # Fail if the given PSGallery is not registered. + $previousGalleryInstallationPolicy = (Get-PSRepository -Name $Gallery -ErrorAction 'Stop').Trusted + + $updatedGalleryInstallationPolicy = $false + + if ($previousGalleryInstallationPolicy -ne $true) + { + $updatedGalleryInstallationPolicy = $true + + # Only change policy if the repository is not trusted + Set-PSRepository -Name $Gallery -InstallationPolicy 'Trusted' -ErrorAction 'Ignore' } +} - if (-not $psDependModule) +try +{ + # Check if legacy PowerShellGet and PSDepend must be used. + if (-not ($UseModuleFast -or $UsePSResourceGet)) { - # PSDepend module not found, installing or saving it. - if ($PSDependTarget -in 'CurrentUser', 'AllUsers') + Write-Progress -Activity 'Bootstrap:' -PercentComplete 25 -CurrentOperation 'Checking PowerShellGet' + + # Ensure the module is loaded and retrieve the version you have. + $powerShellGetVersion = (Import-Module -Name 'PowerShellGet' -PassThru -ErrorAction 'SilentlyContinue').Version + + Write-Verbose -Message "Bootstrap: The PowerShellGet version is $powerShellGetVersion" + + # Versions below 2.0 are considered old, unreliable & not recommended + if (-not $powerShellGetVersion -or ($powerShellGetVersion -lt [System.Version] '2.0' -and -not $AllowOldPowerShellGetModule)) { - Write-Debug -Message "PSDepend module not found. Attempting to install from Gallery '$Gallery'." + Write-Progress -Activity 'Bootstrap:' -PercentComplete 40 -CurrentOperation 'Fetching newer version of PowerShellGet' + + # PowerShellGet module not found, installing or saving it. + if ($PSDependTarget -in 'CurrentUser', 'AllUsers') + { + Write-Debug -Message "PowerShellGet module not found. Attempting to install from Gallery $Gallery." + + Write-Warning -Message "Installing PowerShellGet in $PSDependTarget Scope." + + $installPowerShellGetParameters = @{ + Name = 'PowerShellGet' + Force = $true + SkipPublisherCheck = $true + AllowClobber = $true + Scope = $Scope + Repository = $Gallery + MaximumVersion = '2.8.999' + } - Write-Warning -Message "Installing PSDepend in $PSDependTarget Scope." + switch ($PSBoundParameters.Keys) + { + 'Proxy' + { + $installPowerShellGetParameters.Add('Proxy', $Proxy) + } + + 'ProxyCredential' + { + $installPowerShellGetParameters.Add('ProxyCredential', $ProxyCredential) + } + + 'GalleryCredential' + { + $installPowerShellGetParameters.Add('Credential', $GalleryCredential) + } + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 60 -CurrentOperation 'Installing newer version of PowerShellGet' - $installPSDependParameters = @{ - Name = 'PSDepend' - Repository = $Gallery - Force = $true - Scope = $PSDependTarget - SkipPublisherCheck = $true - AllowClobber = $true + Install-Module @installPowerShellGetParameters } + else + { + Write-Debug -Message "PowerShellGet module not found. Attempting to Save from Gallery $Gallery to $PSDependTarget" + + $saveModuleParameters = @{ + Name = 'PowerShellGet' + Repository = $Gallery + Path = $PSDependTarget + Force = $true + MaximumVersion = '2.8.999' + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 60 -CurrentOperation "Saving PowerShellGet from $Gallery to $Scope" + + Save-Module @saveModuleParameters + } + + Write-Debug -Message 'Removing previous versions of PowerShellGet and PackageManagement from session' - if ($MinimumPSDependVersion) + Get-Module -Name 'PowerShellGet' -All | Remove-Module -Force -ErrorAction 'SilentlyContinue' + Get-Module -Name 'PackageManagement' -All | Remove-Module -Force + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 65 -CurrentOperation 'Loading latest version of PowerShellGet' + + Write-Debug -Message 'Importing latest PowerShellGet and PackageManagement versions into session' + + if ($AllowOldPowerShellGetModule) { - $installPSDependParameters.Add('MinimumVersion', $MinimumPSDependVersion) + $powerShellGetModule = Import-Module -Name 'PowerShellGet' -Force -PassThru } + else + { + Import-Module -Name 'PackageManagement' -MinimumVersion '1.4.8.1' -Force - Write-Progress -Activity 'Bootstrap:' -PercentComplete 75 -CurrentOperation "Installing PSDepend from $Gallery" + $powerShellGetModule = Import-Module -Name 'PowerShellGet' -MinimumVersion '2.2.5' -Force -PassThru + } - Install-Module @installPSDependParameters + $powerShellGetVersion = $powerShellGetModule.Version.ToString() + + Write-Information -MessageData "Bootstrap: PowerShellGet version loaded is $powerShellGetVersion" } - else - { - Write-Debug -Message "PSDepend module not found. Attempting to Save from Gallery $Gallery to $PSDependTarget" - $saveModuleParameters = @{ - Name = 'PSDepend' - Repository = $Gallery - Path = $PSDependTarget - Force = $true + # Try to import the PSDepend module from the available modules. + $getModuleParameters = @{ + Name = 'PSDepend' + ListAvailable = $true + } + + $psDependModule = Get-Module @getModuleParameters + + if ($PSBoundParameters.ContainsKey('MinimumPSDependVersion')) + { + try + { + $psDependModule = $psDependModule | Where-Object -FilterScript { $_.Version -ge $MinimumPSDependVersion } } + catch + { + throw ('There was a problem finding the minimum version of PSDepend. Error: {0}' -f $_) + } + } + + if (-not $psDependModule) + { + Write-Debug -Message 'PSDepend module not found.' + + # PSDepend module not found, installing or saving it. + if ($PSDependTarget -in 'CurrentUser', 'AllUsers') + { + Write-Debug -Message "Attempting to install from Gallery '$Gallery'." + + Write-Warning -Message "Installing PSDepend in $PSDependTarget Scope." + + $installPSDependParameters = @{ + Name = 'PSDepend' + Repository = $Gallery + Force = $true + Scope = $PSDependTarget + SkipPublisherCheck = $true + AllowClobber = $true + } + + if ($MinimumPSDependVersion) + { + $installPSDependParameters.Add('MinimumVersion', $MinimumPSDependVersion) + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 75 -CurrentOperation "Installing PSDepend from $Gallery" - if ($MinimumPSDependVersion) + Install-Module @installPSDependParameters + } + else { - $saveModuleParameters.add('MinimumVersion', $MinimumPSDependVersion) + Write-Debug -Message "Attempting to Save from Gallery $Gallery to $PSDependTarget" + + $saveModuleParameters = @{ + Name = 'PSDepend' + Repository = $Gallery + Path = $PSDependTarget + Force = $true + } + + if ($MinimumPSDependVersion) + { + $saveModuleParameters.add('MinimumVersion', $MinimumPSDependVersion) + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 75 -CurrentOperation "Saving PSDepend from $Gallery to $PSDependTarget" + + Save-Module @saveModuleParameters } + } - Write-Progress -Activity 'Bootstrap:' -PercentComplete 75 -CurrentOperation "Saving & Importing PSDepend from $Gallery to $Scope" + Write-Progress -Activity 'Bootstrap:' -PercentComplete 80 -CurrentOperation 'Importing PSDepend' - Save-Module @saveModuleParameters + $importModulePSDependParameters = @{ + Name = 'PSDepend' + ErrorAction = 'Stop' + Force = $true } - } - Write-Progress -Activity 'Bootstrap:' -PercentComplete 80 -CurrentOperation 'Loading PSDepend' + if ($PSBoundParameters.ContainsKey('MinimumPSDependVersion')) + { + $importModulePSDependParameters.Add('MinimumVersion', $MinimumPSDependVersion) + } + + # We should have successfully bootstrapped PSDepend. Fail if not available. + $null = Import-Module @importModulePSDependParameters + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 81 -CurrentOperation 'Invoke PSDepend' + + if ($WithYAML) + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 82 -CurrentOperation 'Verifying PowerShell module PowerShell-Yaml' - $importModulePSDependParameters = @{ - Name = 'PSDepend' - ErrorAction = 'Stop' - Force = $true + if (-not (Get-Module -ListAvailable -Name 'PowerShell-Yaml')) + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 85 -CurrentOperation 'Installing PowerShell module PowerShell-Yaml' + + Write-Verbose -Message "PowerShell-Yaml module not found. Attempting to Save from Gallery '$Gallery' to '$PSDependTarget'." + + $SaveModuleParam = @{ + Name = 'PowerShell-Yaml' + Repository = $Gallery + Path = $PSDependTarget + Force = $true + } + + Save-Module @SaveModuleParam + } + else + { + Write-Verbose -Message 'PowerShell-Yaml is already available' + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 88 -CurrentOperation 'Importing PowerShell module PowerShell-Yaml' + } } - if ($PSBoundParameters.ContainsKey('MinimumPSDependVersion')) + if (Test-Path -Path $DependencyFile) { - $importModulePSDependParameters.Add('MinimumVersion', $MinimumPSDependVersion) - } + if ($UseModuleFast -or $UsePSResourceGet) + { + $requiredModules = Import-PowerShellDataFile -Path $DependencyFile - # We should have successfully bootstrapped PSDepend. Fail if not available. - $null = Import-Module @importModulePSDependParameters + $requiredModules = $requiredModules.GetEnumerator() | + Where-Object -FilterScript { $_.Name -ne 'PSDependOptions' } - if ($WithYAML) - { - Write-Progress -Activity 'Bootstrap:' -PercentComplete 82 -CurrentOperation 'Verifying PowerShell module PowerShell-Yaml' + if ($UseModuleFast) + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 90 -CurrentOperation 'Invoking ModuleFast' - if (-not (Get-Module -ListAvailable -Name 'PowerShell-Yaml')) - { - Write-Progress -Activity 'Bootstrap:' -PercentComplete 85 -CurrentOperation 'Installing PowerShell module PowerShell-Yaml' + Write-Progress -Activity 'ModuleFast:' -PercentComplete 0 -CurrentOperation 'Restoring Build Dependencies' + + $modulesToSave = @( + 'PSDepend' # Always include PSDepend for backward compatibility. + ) + + if ($WithYAML) + { + $modulesToSave += 'PowerShell-Yaml' + } + + if ($UsePowerShellGetCompatibilityModule) + { + Write-Debug -Message 'PowerShellGet compatibility module is configured to be used.' + + # This is needed to ensure that the PowerShellGet compatibility module works. + $psResourceGetModuleName = 'Microsoft.PowerShell.PSResourceGet' + + if ($PSResourceGetVersion) + { + $modulesToSave += ('{0}:[{1}]' -f $psResourceGetModuleName, $PSResourceGetVersion) + } + else + { + $modulesToSave += $psResourceGetModuleName + } + + $powerShellGetCompatibilityModuleName = 'PowerShellGet' + + if ($UsePowerShellGetCompatibilityModuleVersion) + { + $modulesToSave += ('{0}:[{1}]' -f $powerShellGetCompatibilityModuleName, $UsePowerShellGetCompatibilityModuleVersion) + } + else + { + $modulesToSave += $powerShellGetCompatibilityModuleName + } + } + + foreach ($requiredModule in $requiredModules) + { + # If the RequiredModules.psd1 entry is an Hashtable then special handling is needed. + if ($requiredModule.Value -is [System.Collections.Hashtable]) + { + if (-not $requiredModule.Value.Version) + { + $requiredModuleVersion = 'latest' + } + else + { + $requiredModuleVersion = $requiredModule.Value.Version + } + + if ($requiredModuleVersion -eq 'latest') + { + $moduleNameSuffix = '' + + if ($requiredModule.Value.Parameters.AllowPrerelease -eq $true) + { + <# + Adding '!' to the module name indicate to ModuleFast + that is should also evaluate pre-releases. + #> + $moduleNameSuffix = '!' + } + + $modulesToSave += ('{0}{1}' -f $requiredModule.Name, $moduleNameSuffix) + } + else + { + $modulesToSave += ('{0}:[{1}]' -f $requiredModule.Name, $requiredModuleVersion) + } + } + else + { + if ($requiredModule.Value -eq 'latest') + { + $modulesToSave += $requiredModule.Name + } + else + { + # Handle different nuget version operators already present. + if ($requiredModule.Value -match '[!|:|[|(|,|>|<|=]') + { + $modulesToSave += ('{0}{1}' -f $requiredModule.Name, $requiredModule.Value) + } + else + { + # Assuming the version is a fixed version. + $modulesToSave += ('{0}:[{1}]' -f $requiredModule.Name, $requiredModule.Value) + } + } + } + } + + Write-Debug -Message ("Required modules to retrieve plan for:`n{0}" -f ($modulesToSave | Out-String)) - Write-Verbose -Message "PowerShell-Yaml module not found. Attempting to Save from Gallery '$Gallery' to '$PSDependTarget'." + $installModuleFastParameters = @{ + Destination = $PSDependTarget + DestinationOnly = $true + NoPSModulePathUpdate = $true + NoProfileUpdate = $true + Update = $true + Confirm = $false + } + + $moduleFastPlan = Install-ModuleFast -Specification $modulesToSave -Plan @installModuleFastParameters + + Write-Debug -Message ("Missing modules that need to be saved:`n{0}" -f ($moduleFastPlan | Out-String)) + + if ($moduleFastPlan) + { + # Clear all modules in plan from the current session so they can be fetched again. + $moduleFastPlan.Name | Get-Module | Remove-Module -Force + + $moduleFastPlan | Install-ModuleFast @installModuleFastParameters + } + else + { + Write-Verbose -Message 'All required modules were already up to date' + } - $SaveModuleParam = @{ - Name = 'PowerShell-Yaml' - Repository = $Gallery - Path = $PSDependTarget - Force = $true + Write-Progress -Activity 'ModuleFast:' -PercentComplete 100 -CurrentOperation 'Dependencies restored' -Completed } - Save-Module @SaveModuleParam + if ($UsePSResourceGet) + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 90 -CurrentOperation 'Invoking PSResourceGet' + + $modulesToSave = @( + @{ + Name = 'PSDepend' # Always include PSDepend for backward compatibility. + } + ) + + if ($WithYAML) + { + $modulesToSave += @{ + Name = 'PowerShell-Yaml' + } + } + + # Prepare hashtable that can be concatenated to the Save-PSResource parameters. + foreach ($requiredModule in $requiredModules) + { + # If the RequiredModules.psd1 entry is an Hashtable then special handling is needed. + if ($requiredModule.Value -is [System.Collections.Hashtable]) + { + $saveModuleHashtable = @{ + Name = $requiredModule.Name + } + + if ($requiredModule.Value.Version -and $requiredModule.Value.Version -ne 'latest') + { + $saveModuleHashtable.Version = $requiredModule.Value.Version + } + + if ($requiredModule.Value.Parameters.AllowPrerelease -eq $true) + { + $saveModuleHashtable.Prerelease = $true + } + + $modulesToSave += $saveModuleHashtable + } + else + { + if ($requiredModule.Value -eq 'latest') + { + $modulesToSave += @{ + Name = $requiredModule.Name + } + } + else + { + $modulesToSave += @{ + Name = $requiredModule.Name + Version = $requiredModule.Value + } + } + } + } + + $percentagePerModule = [System.Math]::Floor(100 / $modulesToSave.Length) + + $progressPercentage = 0 + + Write-Progress -Activity 'PSResourceGet:' -PercentComplete $progressPercentage -CurrentOperation 'Restoring Build Dependencies' + + foreach ($currentModule in $modulesToSave) + { + Write-Progress -Activity 'PSResourceGet:' -PercentComplete $progressPercentage -CurrentOperation 'Restoring Build Dependencies' -Status ('Saving module {0}' -f $savePSResourceParameters.Name) + + $savePSResourceParameters = @{ + Path = $PSDependTarget + TrustRepository = $true + Confirm = $false + } + + # Concatenate the module parameters to the Save-PSResource parameters. + $savePSResourceParameters += $currentModule + + # Modules that Sampler depend on that cannot be refreshed without a new session. + $skipModule = @('PowerShell-Yaml') + + if ($savePSResourceParameters.Name -in $skipModule -and (Get-Module -Name $savePSResourceParameters.Name)) + { + Write-Progress -Activity 'PSResourceGet:' -PercentComplete $progressPercentage -CurrentOperation 'Restoring Build Dependencies' -Status ('Skipping module {0}' -f $savePSResourceParameters.Name) + + Write-Information -MessageData ('Skipping the module {0} since it cannot be refresh while loaded into the session. To refresh the module open a new session and resolve dependencies again.' -f $savePSResourceParameters.Name) -InformationAction 'Continue' + } + else + { + # Clear all module from the current session so any new version fetched will be re-imported. + Get-Module -Name $savePSResourceParameters.Name | Remove-Module -Force + + Save-PSResource @savePSResourceParameters -ErrorVariable 'savePSResourceError' + + if ($savePSResourceError) + { + Write-Warning -Message 'Save-PSResource could not save (replace) one or more dependencies. This can be due to the module is loaded into the session (and referencing assemblies). Close the current session and open a new session and try again.' + } + } + + $progressPercentage += $percentagePerModule + } + + Write-Progress -Activity 'PSResourceGet:' -PercentComplete 100 -CurrentOperation 'Dependencies restored' -Completed + } } else { - Write-Verbose "PowerShell-Yaml is already available" - } - } + Write-Progress -Activity 'Bootstrap:' -PercentComplete 90 -CurrentOperation 'Invoking PSDepend' - Write-Progress -Activity 'Bootstrap:' -PercentComplete 90 -CurrentOperation 'Invoke PSDepend' + Write-Progress -Activity 'PSDepend:' -PercentComplete 0 -CurrentOperation 'Restoring Build Dependencies' - Write-Progress -Activity "PSDepend:" -PercentComplete 0 -CurrentOperation "Restoring Build Dependencies" + $psDependParameters = @{ + Force = $true + Path = $DependencyFile + } - if (Test-Path -Path $DependencyFile) - { - $psDependParameters = @{ - Force = $true - Path = $DependencyFile - } + # TODO: Handle when the Dependency file is in YAML, and -WithYAML is specified. + Invoke-PSDepend @psDependParameters - # TODO: Handle when the Dependency file is in YAML, and -WithYAML is specified. - Invoke-PSDepend @psDependParameters + Write-Progress -Activity 'PSDepend:' -PercentComplete 100 -CurrentOperation 'Dependencies restored' -Completed + } + } + else + { + Write-Warning -Message "The dependency file '$DependencyFile' could not be found." } - Write-Progress -Activity "PSDepend:" -PercentComplete 100 -CurrentOperation "Dependencies restored" -Completed - - Write-Progress -Activity 'Bootstrap:' -PercentComplete 100 -CurrentOperation "Bootstrap complete" -Completed + Write-Progress -Activity 'Bootstrap:' -PercentComplete 100 -CurrentOperation 'Bootstrap complete' -Completed } finally { @@ -465,7 +1019,7 @@ finally Write-Verbose -Message "Reverting private package repository '$Gallery' to previous location URI:s." $registerPSRepositoryParameters = @{ - Name = $previousRegisteredRepository.Name + Name = $previousRegisteredRepository.Name InstallationPolicy = $previousRegisteredRepository.InstallationPolicy } @@ -492,15 +1046,15 @@ finally Register-PSRepository @registerPSRepositoryParameters } - # Only try to revert installation policy if the repository exist - if ((Get-PSRepository -Name $Gallery -ErrorAction 'SilentlyContinue')) + if ($updatedGalleryInstallationPolicy -eq $true -and $previousGalleryInstallationPolicy -ne $true) { - if ($previousGalleryInstallationPolicy -and $previousGalleryInstallationPolicy -ne 'Trusted') + # Only try to revert installation policy if the repository exist + if ((Get-PSRepository -Name $Gallery -ErrorAction 'SilentlyContinue')) { # Reverting the Installation Policy for the given gallery if it was not already trusted - Set-PSRepository -Name $Gallery -InstallationPolicy $previousGalleryInstallationPolicy + Set-PSRepository -Name $Gallery -InstallationPolicy 'Untrusted' } } - Write-Verbose -Message "Project Bootstrapped, returning to Invoke-Build" + Write-Verbose -Message 'Project Bootstrapped, returning to Invoke-Build.' } diff --git a/Resolve-Dependency.psd1 b/Resolve-Dependency.psd1 index 2ae8c0d..07945f8 100644 --- a/Resolve-Dependency.psd1 +++ b/Resolve-Dependency.psd1 @@ -2,4 +2,14 @@ Gallery = 'PSGallery' AllowPrerelease = $false WithYAML = $true + + #UseModuleFast = $true + #ModuleFastVersion = '0.1.2' + #ModuleFastBleedingEdge = $true + + UsePSResourceGet = $true + #PSResourceGetVersion = '1.0.1' + + UsePowerShellGetCompatibilityModule = $true + UsePowerShellGetCompatibilityModuleVersion = '3.0.23-beta23' } diff --git a/build.ps1 b/build.ps1 index 5579df1..f4a0fae 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,6 +1,6 @@ <# .DESCRIPTION - Bootstrap and build script for PowerShell module CI/CD pipeline + Bootstrap and build script for PowerShell module CI/CD pipeline. .PARAMETER Tasks The task or tasks to run. The default value is '.' (runs the default task). @@ -56,6 +56,19 @@ .PARAMETER AutoRestore Not yet written. + + .PARAMETER UseModuleFast + Specifies to use ModuleFast instead of PowerShellGet to resolve dependencies + faster. + + .PARAMETER UsePSResourceGet + Specifies to use PSResourceGet instead of PowerShellGet to resolve dependencies + faster. This can also be configured in Resolve-Dependency.psd1. + + .PARAMETER UsePowerShellGetCompatibilityModule + Specifies to use the compatibility module PowerShellGet. This parameter + only works then the method of downloading dependencies is PSResourceGet. + This can also be configured in Resolve-Dependency.psd1. #> [CmdletBinding()] param @@ -121,7 +134,19 @@ param [Parameter()] [System.Management.Automation.SwitchParameter] - $AutoRestore + $AutoRestore, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $UseModuleFast, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $UsePSResourceGet, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $UsePowerShellGetCompatibilityModule ) <# @@ -132,7 +157,6 @@ param process { - if ($MyInvocation.ScriptName -notLike '*Invoke-Build.ps1') { # Only run the process block through InvokeBuild (look at the Begin block at the bottom of this script). @@ -178,7 +202,7 @@ process ConvertFrom-Yaml -Yaml (Get-Content -Raw $configFile) } - # Native Support for JSON and JSONC (by Removing comments) + # Support for JSON and JSONC (by Removing comments) when module PowerShell-Yaml is available '\.[json|jsonc]' { $jsonFile = Get-Content -Raw -Path $configFile @@ -336,7 +360,7 @@ process } } -Begin +begin { # Find build config if not specified. if (-not $BuildConfig) @@ -450,7 +474,8 @@ Begin if ($ResolveDependency) { - Write-Host -Object "[pre-build] Resolving dependencies." -ForegroundColor Green + Write-Host -Object "[pre-build] Resolving dependencies using preferred method." -ForegroundColor Green + $resolveDependencyParams = @{ } # If BuildConfig is a Yaml file, bootstrap powershell-yaml via ResolveDependency. diff --git a/source/Public/Get-LocalizedData.ps1 b/source/Public/Get-LocalizedData.ps1 index 20c70b8..424d136 100644 --- a/source/Public/Get-LocalizedData.ps1 +++ b/source/Public/Get-LocalizedData.ps1 @@ -27,6 +27,32 @@ user messages in the UI language of the current user. For more information about this and about the format of the .psd1 files, see about_Script_Internationalization. + ```mermaid + graph LR + + Argument{Parameter set?} -->|"Only UICulture + (DefaultUICulture = en-US)"| UseUIC + Argument -->|"Only DefaultUICulture"| GetUIC[[Get OS Culture]] + GetUIC --> LCID127{"Is LCID = 127?
(in variant culture)"} + Argument -->|"Both UICulture and + DefaultUICulture"| UseUIC + UseUIC[Use UICulture] --> LCID127 + LCID127 -->|"No"| SetUIC[[Set UICulture]] + LCID127 -->|"Yes"| UseDC[Use default culture] + UseDC --> SetUIC + SetUIC --> SearchFile[[Find UICulture
localization file]] + SearchFile --> FileExist + FileExist{localization
file exist?} -->|"No"| ParentCulture{Parent culture
exist?} + ParentCulture -->|"Yes"| UseParentC[Use parent culture] + UseParentC --> SetUIC + ParentCulture -->|"No"| EvalDefaultC{Evaluate
default>br>culture?} + EvalDefaultC -->|"Yes"| UseDC + EvalDefaultC -->|"No"| EvalStillLCID127{Still invariant?} + FileExist -->|"Yes"| EvalStillLCID127 + EvalStillLCID127 -->|"Yes, Use Get-LocalizedDataForInvariantCulture"| GetFile[[Get localization strings]] + EvalStillLCID127 -->|"No, Use Import-LocalizedData"| GetFile + ``` + .PARAMETER BindingVariable Specifies the variable into which the text strings are imported. Enter a variable name without a dollar sign ($). @@ -132,11 +158,31 @@ For more information, see about_Script_Internationalization. .EXAMPLE - $script:localizedData = Get-LocalizedData -DefaultUICulture 'en-US' + $script:localizedData = Get-LocalizedData + + Imports the localized strings for the current OS UI culture. If the localized + folder does not exist then the localized strings for the default UI culture + 'en-US' is returned. + + .EXAMPLE + $script:localizedData = Get-LocalizedData -DefaultUICulture 'de-DE' + + Imports the localized strings for the current OS UI culture. If the localized + folder does not exist then the localized strings for the default UI culture + 'de-DE' is returned. + + .EXAMPLE + $script:localizedData = Get-LocalizedData -UICulture 'de-DE' + + Imports the localized strings for UI culture 'de-DE'. If the localized folder + does not exist then the localized strings for the default UI culture 'en-US' + is returned. - This is an example that can be used in DSC resources to import the - localized strings and if the current UI culture localized folder does - not exist the UI culture 'en-US' is returned. + $script:localizedData = Get-LocalizedData -UICulture 'de-DE' -DefaultUICulture 'en-GB' + + Imports the localized strings for UI culture 'de-DE'. If the localized folder + does not exist then the localized strings for the default UI culture + 'en-GB' is returned. #> function Get-LocalizedData { @@ -149,7 +195,7 @@ function Get-LocalizedData [System.String] $BindingVariable, - [Parameter(Position = 1, ParameterSetName = 'TargetedUICulture')] + [Parameter(Position = 1)] [System.String] $UICulture, @@ -165,232 +211,233 @@ function Get-LocalizedData [System.String[]] $SupportedCommand, - [Parameter(Position = 1, ParameterSetName = 'DefaultUICulture')] + [Parameter(Position = 2)] + [ValidateNotNullOrEmpty()] [System.String] $DefaultUICulture = 'en-US' ) - begin + if ($PSBoundParameters.ContainsKey('FileName')) + { + Write-Debug -Message ('Looking for provided file with base name: ''{0}''.' -f $FileName) + } + else { - <# - Because Proxy Command changes the Invocation origin, we need to be explicit - when handing the pipeline back to original command. - #> - if ($PSBoundParameters.ContainsKey('FileName')) + if ($myInvocation.ScriptName) { - Write-Debug -Message ('Looking for provided file with base name: ''{0}''.' -f $FileName) + $file = [System.IO.FileInfo] $myInvocation.ScriptName } else { - if ($myInvocation.ScriptName) - { - $file = [System.IO.FileInfo] $myInvocation.ScriptName - } - else - { - $file = [System.IO.FileInfo] $myInvocation.MyCommand.Module.Path - } + $file = [System.IO.FileInfo] $myInvocation.MyCommand.Module.Path + } - $FileName = $file.BaseName + $FileName = $file.BaseName - $PSBoundParameters.Add('FileName', $file.Name) - Write-Debug -Message ('Looking for resolved file with base name: ''{0}''.' -f $FileName) - } + $PSBoundParameters.Add('FileName', $file.Name) + + Write-Debug -Message ('Looking for resolved file with base name: ''{0}''.' -f $FileName) + } + + if ($PSBoundParameters.ContainsKey('BaseDirectory')) + { + $callingScriptRoot = $BaseDirectory + } + else + { + $callingScriptRoot = $MyInvocation.PSScriptRoot + + $PSBoundParameters.Add('BaseDirectory', $callingScriptRoot) + } + + # If UICulture wasn't specified use the OS configured one, otherwise use the one specified. + if (-not $PSBoundParameters.ContainsKey('UICulture')) + { + $currentCulture = Get-UICulture + + Write-Debug -Message ("Using OS configured culture:`n{0}" -f ($currentCulture | Out-String)) + + $PSBoundParameters['UICulture'] = $currentCulture.Name + } + else + { + $currentCulture = New-Object -TypeName 'System.Globalization.CultureInfo' -ArgumentList @($UICulture) + + Write-Debug -Message ("Using specified culture:`n{0}" -f ($currentCulture | Out-String)) + } - if ($PSBoundParameters.ContainsKey('BaseDirectory')) + <# + If the LCID is 127 (invariant) then use default UI culture anyway. + If we can't create the CultureInfo object, it's probably because the + Globalization-invariant mode is enabled for the DotNet runtime (breaking change in .Net) + See more information in issue https://github.com/dsccommunity/DscResource.Common/issues/11. + https://docs.microsoft.com/en-us/dotnet/core/compatibility/globalization/6.0/culture-creation-invariant-mode + #> + + $evaluateDefaultCulture = $true + + if ($currentCulture.LCID -eq 127) # cSpell: ignore LCID + { + try { - $callingScriptRoot = $BaseDirectory + # Current culture is invariant, let's directly evaluate the DefaultUICulture + $currentCulture = New-Object -TypeName 'System.Globalization.CultureInfo' -ArgumentList @($DefaultUICulture) + + Write-Debug -Message ("Invariant culture. Using default culture instead:`n{0}" -f ($currentCulture | Out-String)) + + # No need to evaluate the DefaultUICulture later, as we'll start with this (in the while loop below) + $evaluateDefaultCulture = $false } - else + catch { - $callingScriptRoot = $MyInvocation.PSScriptRoot - $PSBoundParameters.Add('BaseDirectory', $callingScriptRoot) + # The code will now skip to the InvokeCommand part and execute the Get-LocalizedDataForInvariantCulture + # function instead of Import-LocalizedData. + + Write-Debug -Message 'The Globalization-Invariant mode is enabled, only the Invariant Culture is allowed.' } - # If we're not looking for a specific UICulture, but looking for current culture, one of its parent, or the default. - if (-not $PSBoundParameters.ContainsKey('UICulture') -and $PSBoundParameters.ContainsKey('DefaultUICulture')) + Write-Debug -Message ('Setting parameter UICulture to ''{0}''.' -f $DefaultUICulture) + + $PSBoundParameters['UICulture'] = $DefaultUICulture + } + + [System.String] $languageFile = '' + + [System.String[]] $localizedFileNamesToTry = @( + ('{0}.psd1' -f $FileName) + ('{0}.strings.psd1' -f $FileName) + ) + + while (-not [System.String]::IsNullOrEmpty($currentCulture.Name) -and [System.String]::IsNullOrEmpty($languageFile)) + { + Write-Debug -Message ('Looking for Localized data file using the current culture ''{0}''.' -f $currentCulture.Name) + + foreach ($localizedFileName in $localizedFileNamesToTry) { - <# - We don't want the resolution to eventually return the ModuleManifest - so we run the same GetFilePath() logic than here: - https://github.com/PowerShell/PowerShell/blob/master/src/Microsoft.PowerShell.Commands.Utility/commands/utility/Import-LocalizedData.cs#L302-L333 - and if we see it will return the wrong thing, set the UICulture to DefaultUI culture, and return the logic to Import-LocalizedData. - - If the LCID is 127 (invariant) then use default UI culture anyway. - If we can't create the CultureInfo object, it's probably because the Globalization-invariant mode is enabled for the DotNet runtime (breaking change in .Net) - See more information in issue https://github.com/dsccommunity/DscResource.Common/issues/11. - https://docs.microsoft.com/en-us/dotnet/core/compatibility/globalization/6.0/culture-creation-invariant-mode - #> - - $currentCulture = Get-UICulture - $evaluateDefaultCulture = $true - - if ($currentCulture.LCID -eq 127) + $filePath = [System.IO.Path]::Combine($callingScriptRoot, $CurrentCulture.Name, $localizedFileName) + + if (Test-Path -Path $filePath) { - try - { - # Current culture is invariant, let's directly evaluate the DefaultUICulture - $currentCulture = New-Object -TypeName 'System.Globalization.CultureInfo' -ArgumentList @($DefaultUICulture) - # No need to evaluate the DefaultUICulture later, as we'll start with this (in the while loop below) - $evaluateDefaultCulture = $false - } - catch - { - Write-Debug -Message 'The Globalization-Invariant mode is enabled, only the Invariant Culture is allowed.' - # The code will now skip to the InvokeCommand part and execute the Get-LocalizedDataForInvariantCulture - } + Write-Debug -Message "Found '$filePath'." + + $languageFile = $filePath - $PSBoundParameters['UICulture'] = $DefaultUICulture + # Set the filename to the file we found. + $PSBoundParameters['FileName'] = $localizedFileName + + # Exit loop if as we found the first filename. + break + } + else + { + Write-Debug -Message "File '$filePath' not found." } + } + + # If the file wasn't found one, try parent culture or the default culture. + if ([System.String]::IsNullOrEmpty($languageFile)) + { + # Evaluate the parent culture if there is a valid one (not invariant culture). + if ($currentCulture.Parent -and [System.String] $currentCulture.Parent.Name) + { + $currentCulture = $currentCulture.Parent + + Write-Debug -Message ('Setting parameter UICulture to ''{0}''.' -f $currentCulture.Name) - [string] $languageFile = '' - [string[]] $localizedFileNamesToTry = @( - ('{0}.psd1' -f $FileName) - ('{0}.strings.psd1' -f $FileName) - ) + $PSBoundParameters['UICulture'] = $currentCulture.Name - while (-not [string]::IsNullOrEmpty($currentCulture.Name) -and [String]::IsNullOrEmpty($languageFile)) + Write-Debug -Message ("Did not find matching file for current culture, testing parent culture:`n{0}" -f ($currentCulture | Out-String)) + } + else { - Write-Debug -Message ('Looking for Localized data file using the current culture ''{0}''.' -f $currentCulture.Name) - foreach ($localizedFileName in $localizedFileNamesToTry) + # If we haven't evaluated the default culture yet, do it now. + if ($evaluateDefaultCulture) { - $filePath = [System.IO.Path]::Combine($callingScriptRoot, $CurrentCulture.Name, $localizedFileName) - if (Test-Path -Path $filePath) - { - Write-Debug -Message "Found '$filePath'." - $languageFile = $filePath - # Set the filename to the file we found. - $PSBoundParameters['FileName'] = $localizedFileName - # Exit loop if as we found the first filename. - break - } - else - { - Write-Debug -Message "File '$filePath' not found." - } - } + $evaluateDefaultCulture = $false - if ([String]::IsNullOrEmpty($languageFile)) - { <# - Evaluate the parent culture if there is a valid one (not Invariant). - - If the parent culture is LCID 127 then move to the default culture. - See more information in issue https://github.com/dsccommunity/DscResource.Common/issues/11. + Evaluating the default UI culture (which defaults to 'en-US'). + If the default UI culture cannot be resolved, we'll revert + to the current culture because then most likely the invariant + mode is enabled for the DotNet runtime. #> - if ($currentCulture.Parent -and [string]$currentCulture.Parent.Name) + try { - $currentCulture = $currentCulture.Parent + $currentCulture = New-Object -TypeName 'System.Globalization.CultureInfo' -ArgumentList @($DefaultUICulture) + + Write-Debug -Message ("Did not find matching file for current or parent culture, testing default culture:`n{0}" -f ($currentCulture | Out-String)) } - else + catch { - if ($evaluateDefaultCulture) - { - $evaluateDefaultCulture = $false - - <# - Could not find localized strings file for the the operating - system UI culture. Evaluating the default UI culture (which - defaults to 'en-US' if not specifically set). - #> - try - { - $currentCulture = New-Object -TypeName 'System.Globalization.CultureInfo' -ArgumentList @($DefaultUICulture) - } - catch - { - Write-Debug -Message ('Unable to create the Default UI Culture [CultureInfo] object, most likely due to invariant mode being enabled.') - $currentCulture = Get-UICulture - # We already tried everything we could, exit the while loop and hand over to Import-LocalizedData or Get-LocalizedDataForInvariantCultureMode - break - } - - $PSBoundParameters['UICulture'] = $DefaultUICulture - } - else - { - <# - Already evaluated everything we could, exit and let - Import-LocalizedData throw an exception. - #> - break - } + # Set the OS culture to revert to invariant culture (LCID 127). + $currentCulture = Get-UICulture + + Write-Debug -Message ("Unable to create the [CultureInfo] object for default culture '{0}', most likely due to invariant mode being enabled. Reverting to current (invariant) culture:`n{1}" -f $DefaultUICulture, ($currentCulture | Out-String)) + + <# + Already tried every possible way. Exit the while loop and hand over to + Import-LocalizedData or Get-LocalizedDataForInvariantCultureMode + #> + break } - } - } - <# - Removes the parameter DefaultUICulture so that isn't used when - calling Import-LocalizedData. - #> - $null = $PSBoundParameters.Remove('DefaultUICulture') - } + Write-Debug -Message ('Setting parameter UICulture to ''{0}''.' -f $DefaultUICulture) - try - { - $outBuffer = $null + $PSBoundParameters['UICulture'] = $DefaultUICulture + } + else + { + Write-Debug -Message 'Already evaluated everything we could, continue and let the command called next throw an exception.' - if ($PSBoundParameters.TryGetValue('OutBuffer', [ref] $outBuffer)) - { - $PSBoundParameters['OutBuffer'] = 1 + break + } } + } + } - if ($currentCulture.LCID -eq 127) - { - # Culture is invariant, working around issue with Import-LocalizedData when pwsh configured as invariant - $wrappedCmd = $ExecutionContext.InvokeCommand.GetCommand('Get-LocalizedDataForInvariantCulture', [System.Management.Automation.CommandTypes]::Function) - $PSBoundParameters.Keys.ForEach({ - if ($_ -notin $wrappedCmd.Parameters.Keys) - { - $PSBoundParameters.Remove($_) - } - }) + if ($currentCulture.LCID -eq 127) + { + $getLocalizedDataForInvariantCultureParameters = Get-Command -Name 'Get-LocalizedDataForInvariantCulture' -ErrorAction 'Stop' - $scriptCmd = { & $wrappedCmd @PSBoundParameters } - } - else + $PSBoundParameters.Keys.ForEach({ + if ($_ -notin $getLocalizedDataForInvariantCultureParameters.Parameters.Keys) { - <# Action when all if and elseif conditions are false #> - $wrappedCmd = $ExecutionContext.InvokeCommand.GetCommand('Microsoft.PowerShell.Utility\Import-LocalizedData', [System.Management.Automation.CommandTypes]::Cmdlet) - $scriptCmd = { & $wrappedCmd @PSBoundParameters } + $PSBoundParameters.Remove($_) } + }) - $steppablePipeline = $scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin) - $steppablePipeline.Begin($PSCmdlet) - } - catch - { - throw - } - } + Write-Debug ('Because culture is invariant, calling Get-LocalizedDataForInvariantCulture using parameters: {0}' -f ($PSBoundParameters | Out-String)) - process + # This works around issue with Import-LocalizedData when pwsh configured as invariant. + $localizedData = Get-LocalizedDataForInvariantCulture @PSBoundParameters + } + else { - try - { - $steppablePipeline.Process($_) - } - catch - { - throw - } + Write-Debug ('Calling Microsoft.PowerShell.Utility\Import-LocalizedData using parameters: {0}' -f ($PSBoundParameters | Out-String)) + + # Removes the parameter DefaultUICulture so that isn't used when calling Import-LocalizedData. + $PSBoundParameters.Remove('DefaultUICulture') + + $localizedData = Microsoft.PowerShell.Utility\Import-LocalizedData @PSBoundParameters } - end + if ($PSBoundParameters.ContainsKey('BindingVariable')) { - if ($BindingVariable -and ($valueToBind = Get-Variable -Name $BindingVariable -ValueOnly -ErrorAction 'Ignore')) + # The command we called returned the localized data in the binding variable. + $boundLocalizedData = Get-Variable -Name $BindingVariable -ValueOnly -ErrorAction 'Ignore' + + if ($boundLocalizedData) { + Write-Debug -Message ('Binding variable ''{0}'' to localized data.' -f $BindingVariable) + # Bringing the variable to the parent scope - Set-Variable -Scope 1 -Name $BindingVariable -Force -ErrorAction 'SilentlyContinue' -Value $valueToBind + Set-Variable -Scope 1 -Name $BindingVariable -Force -ErrorAction 'SilentlyContinue' -Value $boundLocalizedData } + } + else + { + Write-Debug -Message ('Returning localized data.' -f $BindingVariable) - try - { - $steppablePipeline.End() - } - catch - { - throw - } + return $localizedData } } diff --git a/tests/Unit/Public/Get-LocalizedData.Tests.ps1 b/tests/Unit/Public/Get-LocalizedData.Tests.ps1 index c6c6841..d123d95 100644 --- a/tests/Unit/Public/Get-LocalizedData.Tests.ps1 +++ b/tests/Unit/Public/Get-LocalizedData.Tests.ps1 @@ -239,4 +239,95 @@ StringKey = String value } } } + + Context 'When specifying a specific filename and UICulture' { + BeforeAll { + New-Item -Force -Path 'TestDrive:\en-US' -ItemType Directory + + $null = " +ConvertFrom-StringData @`' +# English strings +ParameterBlockParameterAttributeMissing = A [Parameter()] attribute must be the first attribute of each parameter and be on its own line. See https://github.com/PowerShell/DscResources/blob/master/StyleGuidelines.md#correct-format-for-parameter-block +'@ + " | Out-File -Force -FilePath 'TestDrive:\en-US\Strings.psd1' + + "Get-LocalizedData -FileName 'Strings' -UICulture 'en-US' -EA Stop" | + Out-File -Force -FilePath 'TestDrive:\execute.ps1' + } + + It 'Should retrieve the data' { + { $null = &'TestDrive:\execute.ps1' } | Should -Not -Throw + &'TestDrive:\execute.ps1' | Should -Not -BeNullOrEmpty + } + } + + Context 'When specifying a specific filename, UICulture and DefaultUICulture' { + BeforeAll { + New-Item -Force -Path 'TestDrive:\en-US' -ItemType Directory + + $null = " +ConvertFrom-StringData @`' +# English strings +ParameterBlockParameterAttributeMissing = A [Parameter()] attribute must be the first attribute of each parameter and be on its own line. See https://github.com/PowerShell/DscResources/blob/master/StyleGuidelines.md#correct-format-for-parameter-block +'@ + " | Out-File -Force -FilePath 'TestDrive:\en-US\Strings.psd1' + + "Get-LocalizedData -FileName 'Strings' -UICulture 'en-US' -DefaultUICulture 'en-US' -EA Stop" | + Out-File -Force -FilePath 'TestDrive:\execute.ps1' + } + + It 'Should retrieve the data' { + { $null = &'TestDrive:\execute.ps1' } | Should -Not -Throw + &'TestDrive:\execute.ps1' | Should -Not -BeNullOrEmpty + } + } + + Context 'When specifying a specific filename, UICulture and DefaultUICulture but the UICulture does not match any file' { + BeforeAll { + New-Item -Force -Path 'TestDrive:\en-US' -ItemType Directory + + $null = " +ConvertFrom-StringData @`' +# English strings +ParameterBlockParameterAttributeMissing = A [Parameter()] attribute must be the first attribute of each parameter and be on its own line. See https://github.com/PowerShell/DscResources/blob/master/StyleGuidelines.md#correct-format-for-parameter-block +'@ + " | Out-File -Force -FilePath 'TestDrive:\en-US\Strings.psd1' + + "Get-LocalizedData -FileName 'Strings' -UICulture 'fr-FR' -DefaultUICulture 'en-US' -EA Stop" | + Out-File -Force -FilePath 'TestDrive:\execute.ps1' + } + + It 'Should retrieve the data from the DefaultUICulture' { + { $null = &'TestDrive:\execute.ps1' } | Should -Not -Throw + &'TestDrive:\execute.ps1' | Should -Not -BeNullOrEmpty + } + } + + Context 'When the culture LCID is 127' { + BeforeAll { + New-Item -Force -Path 'TestDrive:\en-US' -ItemType Directory + + Mock -CommandName Get-UICulture -MockWith { + return [System.Globalization.CultureInfo]::InvariantCulture + } -ModuleName $script:moduleName + + $null = " +ConvertFrom-StringData @`' +# English strings +ParameterBlockParameterAttributeMissing = A [Parameter()] attribute must be the first attribute of each parameter and be on its own line. See https://github.com/PowerShell/DscResources/blob/master/StyleGuidelines.md#correct-format-for-parameter-block +'@ + " | Out-File -Force -FilePath 'TestDrive:\en-US\Strings.psd1' + + $script = @" +Get-LocalizedData -FileName 'Strings' -DefaultUICulture 'en-US' -EA Stop +"@ + + $script | Out-File -Force -FilePath 'TestDrive:\execute.ps1' + } + + It 'Should retrieve the data' { + { $null = &'TestDrive:\execute.ps1' } | Should -Not -Throw + &'TestDrive:\execute.ps1' | Should -Not -BeNullOrEmpty + } + } }