CacheMeIfYouCan.psm1
using namespace System.Collections.Generic using namespace System.Collections using namespace System.Management.Automation.Language using namespace System.Management.Automation using namespace System.Management $script:DefaultTemplateTypeMapping = @{} $script:ModuleConfig = @{ ExportAggressiveNames = $false VerboseLifetimeMessages = $true ExportDebugFunctions = $false VerboseJson_ArgCompletions = $false PrintExtraSummaryOnTabCompletion = $false WarnWhen_GetValueCommand_KeyDoesNotExist = $true Verbose = @{ OnCacheWrites = $true OnCacheReads = $true } } [hashtable]$script:Cache = @{} if($Script:ModuleConfig.VerboseJson_ArgCompletions) { (Join-Path (gi 'temp:\') 'CacheMeIfYouCan.ArgCompletions.log') | Join-String -op 'CacheMeIfYouCan: VerboseLogging for ArgCompletions is enabled at: ' | write-warning } $script:Color = @{ MedBlue = '#a4dcff' DimBlue = '#7aa1b9' DimFg = '#cbc199' DarkFg = '#555759' DimGreen = '#95d1b0' DimOrange = '#ce8d70' DimPurple = '#c586c0' Dim2Purple = '#c1a6c1' DimYellow = '#dcdcaa' MedGreen = '#4cd189' } function WriteFg { # Internal Ansi color wrapper param( [object]$Color ) if( [string]::IsNullOrEmpty( $Color ) ) { return } $PSStyle.Foreground.FromRgb( $Color ) } function WriteBg { # Internal Ansi color wrapper param( [object]$Color ) if( [string]::IsNullOrEmpty( $Color ) ) { return } $PSStyle.Background.FromRgb( $Color ) } function WriteColor { # Internal Ansi color wrapper param( [object]$ColorFg, [object]$ColorBg ) if( [string]::IsNullOrEmpty( $ColorFg ) -and [string]::IsNullOrEmpty( $ColorBg ) ) { return } @( WriteFg $ColorFg WriteBg $ColorBg ) -join '' } function Format-CacheMeStatus { <# .SYNOPSIS render the optional color messages .example CacheMe.RenderStatus -Name 'Gci_1' -Status Fresh .example $record = Get-CacheMe (Get-CacheMeKeys|Select -first 1) -PassThru CacheMe.RenderStatus -Name $record.Name -Status Hit -Time $record.CalculationDuration #> [Alias( 'CacheMe.RenderStatus' )] param( [Alias('Name')] [string]$Key, [ArgumentCompletions( 'Hit', 'Miss', 'Stale', 'Fresh', 'Read', 'Write' )] [string]$Status, [Nullable[timespan]]$Time ) $StateColor = Switch -regex ($Status) { 'Stale|Write|Miss' { $Color.DimOrange ; break } 'Hit|Read|Fresh' { $Color.DimGreen ; break } default { $Color.DimFg } } $CPre1 = WriteFg $Color.DimOrange $CPre1 = WriteFg $Color.DimFg $CPre2 = WriteFg $Color.DimPurple $CPre2 = WriteFg $Color.DimYellow $CPre2 = $PSStyle.Reset $CPre1 = $PSStyle.Reset $CPre2 = WriteFg $Color.DimYellow $CPre1 = WriteFg $Color.DarkFg $CPre4 = WriteFg $Color.Dim2Purple if( $Time ) { $TimeAsMs = $Time.TotalMilliSeconds.ToSTring('n1') $TimeSuffix = " ${CPre1}(${CPre4}${TimeAsMs} ms${CPre1})" } else { $TimeSuffix = '' } $CPre3 = WriteFg $StateColor $Space = ': ' $Space = '' "${CPre1}Cache ${CPre2}${Key}${CPre1}${SPace}${CPre3}${Status}${TimeSuffix}" } function Cache._Read { <# .SYNOPSIS Internal helper, that encapsulates reading keys .LINK Cache._Read .LINK Cache._Write #> # [Alias('Cache._Read')] [OutputType( [CachedRecord] )] [CmdletBinding()] param( [Parameter(Position=0, Mandatory)] [Alias('Name')] [ArgumentCompleter( [CachedKeyNameArgumentCompleter] )] [string]$KeyName, [switch]$ErrorWhenMissing ) [bool]$exists? = $script:Cache.ContainsKey( $KeyName ) if( $ErrorWhenMissing -and -not $exists? ) { if($script:ModuleConfig.WarnWhen_GetValueCommand_KeyDoesNotExist) { $PSCmdlet.MyInvocation.ScriptName | Join-String -op 'Cmdlet.MyInvo.Script: ' | Write-Debug throw ( $KeyName | Join-String -f 'CacheMe::_Read: Tried reading from a key that does not exist! Key = "{0}"') } else { $KeyName | Join-String -f 'CacheMe::_Read: Tried reading from a key that does not exist! Key = "{0}"' | Write-Error # | Write-Debug } } $record = $script:Cache[ $KeyName ] return $record } function Cache._Write { <# .SYNOPSIS Internal helper, that encapsulates writing keys .LINK Cache._Read .LINK Cache._Write #> # [Alias('Cache._Write')] [CmdletBinding()] param( # Name of key [ValidateNotNullOrWhiteSpace()] [ArgumentCompleter( { [CachedKeyNameArgumentCompleter] } )] # [ValidateNotNullOrEmpty()] [Parameter(Mandatory, Position=0)] [string]$KeyName, # maybe this should allow caching in circumstances # [ValidateNotNullOrWhiteSpace()] # [ValidateNotNullOrEmpty()] # [Parameter(Mandatory, ValueFromPipeline )] [Parameter(Mandatory, Position = 1 )] [CachedRecord]$CachedRecord ) if(-not $PSBoundParameters.ContainsKey('CachedRecord')){ throw 'MissingArgException' } if( $CachedRecord -isnot [CachedRecord] ) { throw 'ShouldNeverReachInvalidParameterTypeException' } $script:Cache[ $KeyName ] = $Record } function WhenContainsSpaces-FormatQuotes { param( [string]$Text, [switch]$DoubleQuote ) $hasSpaces = $Text -match ' ' $hasSingle = $Text -match "[']+" $hasDouble = $Text -match '["]+' $splat = @{} if($DoubleQuote) { $splat.DoubleQuote = $True } else { $splat.SingleQuote = $True } $hasSpaces ? ( Join-String -in $Text @splat ) : $Text } class CachedKeyNameArgumentCompleter : System.Management.Automation.IArgumentCompleter { <# it supports names with spaces #> [IEnumerable[CompletionResult]] CompleteArgument( [string] $CommandName, [string] $ParameterName, [string] $WordToComplete, [CommandAst] $CommandAst, [IDictionary] $FakeBoundParameters ) { [List[CompletionResult]]$Completions = @() if($Script:ModuleConfig.VerboseJson_ArgCompletions) { $Script:Cache | ConvertTo-Json -wa 'ignore' -depth 3 | set-content -path (Join-Path (gi 'temp:\') 'CacheMeIfYouCan.ArgCompletions.log') } $script:Cache.GetEnumerator() | % { $Item = $_.Value $toMatch = $Item.Name # $hasSpaces = ($Item.Name -match ' ') # $quotedName = if( $hasSpaces ) { # Join-String -in $Item.Name -SingleQuote # } else { # $Item.Name # } # $toCompleteAs = $quotedName $toCompleteAs = WhenContainsSpaces-FormatQuotes -Text $Item.Name if( $toMatch -notmatch [regex]::escape( $WordToComplete )) { return } $Completions.Add( [CompletionResult]::new( $toCompleteAs, $toMatch, 'ParameterValue', $Item.Tooltip() ) ) } if( $script:ModuleConfig.PrintExtraSummaryOnTabCompletion) { "`n" | write-host $Completions | format-table | out-string | write-host #-bg $Script:Color.DimPurple "`n" | write-host } return $Completions } } class CachedRecord { # Todo: make me [string]$Name # created by [scriptblock]$ScriptBlock [timespan]$CalculationDuration [datetime]$EvaluatedAt [string]$ValueKind [object]$Value [hashtable]$Metadata [timespan]Age () { return [datetime]::Now - $This.EvaluatedAt } [string] ToString() { return '[CacheRecord := Name: {0}, Age: {1} ]' -f @( $This.Name $This.Age().TotalSeconds | Join-String -f '{0:n2}' ) } [string] Tooltip() { # used for custom rendering for tooltips return $This.ToString() } } function CacheMe.ColorTest { $script:Color.GetEnumerator() | %{ $_.Key | New-Text -fg $_.Value | Join-String }| Join-String -sep "`n" } function Clear-CacheMe { [Alias('CacheMe.Clear')] param() 'clearing all cached values...' | write-host -fg 'orange' $script:Cache.Clear() } function Get-CacheMeRecords { <# .SYNOPSIS enumerates the raw [CachedRecord[]] records #> [OutputType( [CachedRecord] )] [Alias()] param() # ( $script:Cache.Keys ).count $script:Cache.GetEnumerator() | %{ $_.Value } } function CacheMe.ListKeys { <# .SYNOPSIS Returns key names as untouched strings. Writes optional colors to to Write-Information .DESCRIPTION You'll .EXAMPLE Pwsh7 🐒 > $names = CacheMe.ListKeys > $Names -join ', ' All_Modules, gcm_less .EXAMPLE Pwsh7 🐒 > $names = CacheMe.ListKeys -infa Continue | Join.UL keys => All_Modules, gcm_less - All_Modules - gcm_less .EXAMPLE Pwsh7 🐒 > CacheMe.ListKeys | Join-String -sep ', ' -SingleQuote 'All_Modules', 'gcm_less' .EXAMPLE Pwsh7 🐒> foreach($x in (CacheMe.ListKeys)) { "DoStuff: $x" } #> [Alias( 'Get-CacheMe.Keys', 'Get-CacheMeKeys' )] [CmdletBinding()] [OutputType('String[]')] param() $PSStyle.OutputRendering = 'ansi' Cache._Read -KeyName foo|ft -AutoSize | Out-String | Write-Information [string[]]$Keys = @($script:Cache).Keys?.Clone() | Sort-Object -Unique $Keys | join-String -sep ', ' -p { $_ | New-Text -fg 'gray70' -bg 'gray40' } | Join-String -op 'keys => ' | Write-Information return $Keys } # function # was: CacheMe.Add-CachedValue { function Set-CacheMeValue { <# .SYNOPSIS # Add a value to the cache, if not already cached, then it's an no-op .DESCRIPTION minimal, fast, caching of random results .EXAMPLE Cache.Value -Name 'gmo-noargs' -ScriptBlock { get-module } -Verbose -Force | % Value| Out-Null .EXAMPLE Cache.Value -Name <tab>'gmo-noargs' # returns value .EXAMPLE $x = Namo.CachedValue -Name 'gmo-list' { get-module -ListAvailable } -Verbose # totalTime: 0.02 ms #> [Alias('Set-CacheMe')] [CmdletBinding()] param( [Alias('Label')] [Parameter(Mandatory)] [ValidateNotNullOrWhiteSpace()] [ArgumentCompleter( [CachedKeyNameArgumentCompleter] )] [string]$Name, # echo cached value [switch]$PassThru, [Parameter(Mandatory)] [Alias('Expression', 'SB', 'E')] [scriptblock]$ScriptBlock ) $addCacheMeValueSplat = @{ Name = $Name PassThru = $PassThru ScriptBlock = $ScriptBlock Force = $True } Add-CacheMeValue @addCacheMeValueSplat } function Add-CacheMeValue { <# .SYNOPSIS # Add a value to the cache, if not already cached, then it's an no-op .DESCRIPTION PassThru will always return a value. If it's not expired, returns the cached value rather then the scriptBlock parameter. minimal, fast, caching of scriptblocks .EXAMPLE Cache.Value -Name 'gmo-noargs' -ScriptBlock { get-module } -Verbose -Force | % Value| Out-Null .EXAMPLE Cache.Value -Name <tab>'gmo-noargs' # returns value .EXAMPLE $x = Namo.CachedValue -Name 'gmo-list' { get-module -ListAvailable } -Verbose # totalTime: 0.02 ms #> [Alias('Add-CacheMe')] [CmdletBinding()] param( [Alias('Label')] [Parameter(Mandatory)] [ValidateNotNullOrWhiteSpace()] [ArgumentCompleter( [CachedKeyNameArgumentCompleter] )] [string]$Name, # echo cached value [switch]$PassThru, [Parameter(Mandatory)] [Alias('Expression', 'SB', 'E')] [scriptblock]$ScriptBlock, # force calculation [Alias('Rebuild')] [switch]$Force ) [datetime]$Start = [datetime]::Now [datetime]$End = $Start [bool]$ValueIsStale = $false if($Force) { $valueIsStale = $true } if( -not $script:Cache.ContainsKey($Name) ) { $valueIsStale = $true } 'Value for Key: {0} is stale?: {1}' -f @( $Name, $ValueIsStale ) | write-verbose if( -not $ValueIsStale ) { # value because it might not be expired with passthru $record = Cache._Read -keyName $Name -ErrorWhenMissing:$False if( $PassThru ) { return $record.Value } return } try { $newValue = & $ScriptBlock } catch { 'CacheMeIfYouCan:Add-CacheMeValue: Exception: Namo.CachedValue( {0} ) invoked by {1}: {2}' -f @( $Name $PSCommandPath $_ ) | write-verbose $_ | write-warning $newValue = $Null return } $end = [datetime]::Now $delta = $end - $start [CachedRecord]$record = @{ CalculationDuration = $delta EvaluatedAt = $Start Name = $Name ScriptBlock = $ScriptBlock Value = $newValue ValueKind = @( '{0} of {1} тип {2}' -f @( ($newValue)?.GetType().Name $newValue.Count @( $newValue)[0]?.GetType().Name ) ) Metadata = @{ SourceCommand = $PSCommandPath SourcePSScriptRoot = $PSScriptRoot MyCmdName = $MyInvocation.MyCommand.Name MyModule = $MyInvocation.MyCommand.ModuleName MyScriptLine = $MyInvocation.ScriptLineNumber MyScriptName = $MyInvocation.ScriptName PathString = $MyInvocation | Join-String -p { $_.ScriptName, $_.ScriptLineNumber -join ':' } } } Cache._Write -KeyName $Record.Name -CachedRecord $record Format-CacheMeStatus -Key $KeyName -Status Write -Time $Record.CalculationDuration | Write-Information -infa 'continue' $record = Cache._Read -keyName $Record.Name -ErrorWhenMissing:$False # return the value because it might not be expired if( $PassThru ) { return $record.Value } } function Get-CacheMeValue { <# .SYNOPSIS Read cached values. Key names autocomplete. .EXAMPLE Get-CacheMe -Name 'key' Get-CacheMe -Name 'key' -ErrorOnMissingKey Get-CacheMe -Name 'key' -PassThru #> [Alias('Get-CacheMe')] [CmdletBinding()] param( [Alias('Label')] [Parameter(Mandatory)] [ValidateNotNullOrWhiteSpace()] [ArgumentCompleter( [CachedKeyNameArgumentCompleter] )] [string]$Name, # returns the [CacheRecord] instead of the raw value [switch]$PassThru, # off means missing keys return nothing, silently [switch]$ErrorOnMissingKey ) [CachedRecord]$record = Cache._Read -KeyName $Name -ErrorWhenMissing:$ErrorOnMissingKey if( $PassThru ) { return $Record } return $record.Value } function CacheMe.ProxyFunc { <# .SYNOPSIS experimenting with a proxy function that supports multiple commandlets .EXAMPLE # is: Add-CacheMeValue CacheMe -Name 'gcm' -ScriptBlock { ... } # is: Set-CacheMeValue CacheMe -Name 'gcm' -ScriptBlock { ... } -Force # is: Get-CacheMeValue CacheMe -Name 'gcm' # is: Get-CacheMeValue CacheMe -Name 'missing' -ErrorOnMissingKey # is: Get-CacheMeKeys CacheMe #> [Alias('CacheMe')] [CmdletBinding()] param( [Alias('Label')] [Parameter(Position=0)] [ArgumentCompleter( [CachedKeyNameArgumentCompleter] )] [string]$Name, # -PassThru returns the raw [CacheRecord], instead of the value [switch]$PassThru, [Parameter( Position=1, Mandatory, ParameterSetName = 'FromScriptBlock' )] [Alias('Expression', 'SB', 'E')] [scriptblock]$ScriptBlock, # force calculation [Alias('Rebuild')] [switch]$Force, # move to another cmdlet when exporting module [switch]$FullReset, [switch]$ErrorOnMissingKey, # this mode collects inputs from the pipeline to save # in comparison to the scriptblock invoke version [Parameter( # Mandatory, ParameterSetName = 'FromPipeline', ValueFromPipeline )] [Alias('Values', 'Item', 'Records', 'Data', 'InObj','PipelineInputObject')] [object[]]$InputObject ) begin { if( $FullReset ) { Clear-CacheMe } [List[Object]]$Items = @() } process { [bool]$Using_SmartAlias_Set = $PSCmdlet.MyInvocation.InvocationName -match '\bSet\b' [bool]$Param_SB_IsDefined = -not [string]::IsNullOrWhiteSpace($ScriptBlock) [bool]$Param_Pipeline_IsDefined = $PSBoundParameters.ContainsKey('InputObject') -or $PSCmdlet.MyInvocation.ExpectingInput if( -not $Name ) { Get-CacheMeKeys return } if( $PSBoundParameters.ContainsKey('InputObject') -and [String]::IsNullOrWhiteSpace( $Name ) ) { # -not $PSBoundParameters.ContainsKey('Name') ) { throw "Expected Key is blank!" } if($Param_Pipeline_IsDefined) { $Items.AddRange(@($InputObject)) return } else { if( -not $Param_SB_IsDefined ) { Get-CacheMe -Name $Name -PassThru:$PassThru -ErrorOnMissingKey:$ErrorOnMissingKey return } else { $AddCacheMeSplat = @{ Name = $Name PassThru = $PassThru ScriptBlock = $ScriptBlock Force = $Force } if( $Using_SmartAlias_Set ) { $AddCacheMeSplat.Force = $True } Add-CacheMe @AddCacheMeSplat return } } throw 'ShouldNeverReachUnlessLogicChanged' } end { if($Param_Pipeline_IsDefined) { $AddCacheMeSplat = @{ Name = $Name PassThru = $PassThru ScriptBlock = { $Items } Force = $Force } if( $Using_SmartAlias_Set ) { $AddCacheMeSplat.Force = $True } Add-CacheMe @AddCacheMeSplat return } else { return } throw 'ShouldNeverReachUnlessLogicChanged' } } function Out-CacheMe { <# .SYNOPSIS Pipe values that you'd update the cache #> [CmdletBinding()] param( # label/key name [Alias('KeyName', 'Id', 'Key')] [ValidateNotNullOrEmpty()] [Parameter(Mandatory, Position = 0)] [string]$Name, # pipe anything [Parameter(Mandatory, ValueFromPipeline)] [object[]]$InputObject, # Normally hide output. Passthru to include it [switch]$PassThru ) begin { [List[Object]]$Items = @() } process { $Items.AddRange(@( $InputObject )) } end { 'CacheMe::Out-CachMe captured output, saving to key: "{0}"' -f @( $Name ) | Write-Information -infa 'Continue' Set-CacheMe -Force -Name $Name -ScriptBlock { $Items } } } Export-ModuleMember -Function @( '*-CacheMe*' 'CacheMe.*' if( $ModuleConfig.ExportDebugFunctions ) { # 'WarnIf.NotType' 'Cache*_*' 'Format-*' } if ($ModuleConfig.ExportAggressiveNames) { 'Cache.*' 'Cached*' } ) -Alias @( 'CacheMe' '*-CacheMe*' 'CacheMe.*' if ($ModuleConfig.ExportAggressiveNames) { 'Cached*' 'Cache.*' } if( $ModuleConfig.ExportDebugFunctions ) { # 'WarnIf.NotType' 'Cache*_*' } ) -Variable @( # '*CacheMe*' # 'CacheMe.*' if ($ModuleConfig.ExportAggressiveNames) { # 'Cache.*' # 'Cached*' } ) if($ModuleConfig.ExportDebugFunctions) { 'CacheMeIfYouCan::ExportDebugFunctions is enabled' | Write-warning } if($ModuleConfig.ExportAggressiveNames) { $ModuleConfig.ExportAggressiveNames | Join-String -op 'CacheMeIfYouCan::ModuleConfig.ExportAggressiveNames: ' | write-warning } |