Objective
Isolate environmental variable changes to a code block.
Background
If I want to create a batch scrip
In batch files, all shell variables are environment variables too; therefore, setlocal ... endlocal
provides a local scope for environment variables too.
By contrast, in PowerShell, shell variables (e.g., $var
) are distinct from environment variables (e.g., $env:PATH
) - a distinction that is generally beneficial.
Given that the smallest scope for setting environment variables is the current process - and therefore the entire PowerShell session, you must manage a smaller custom scope manually, if you want to do this in-process (which is what setlocal ... endlocal
does in cmd.exe
, for which PowerShell has no built-in equivalent; to custom-scope shell variables, use & { $var = ...; ... }
):
To ease the pain somewhat, you can use a script block ({ ... }
) to provide a distinct visual grouping of the command, which, when invoked with &
also create a new local scope, so that any aux. variables you define in the script block automatically go out of scope (you can write this as a one-line with ;
-separated commands):
& {
$oldVal, $env:MYLANG = $env:MYLANG, 'EN'
my-cmd dostuff -o out.csv
$env:MYLANG = $oldVal
}
More simply, if there's no preexisting MYLANG
value that must be restored:
& { $env:MYLANG='EN'; my-cmd dostuff -o out.csv; $env:MYLANG=$null }
$oldVal, $env:MYLANG = $env:MYLANG, 'EN'
saves the old value (if any) of $env:MYLANG
in $oldVal
while changing the value to 'EN'
; this technique of assigning to multiple variables at once (known as destructuring assignment in some languages) is explained in Get-Help about_Assignment_Operators, section "ASSIGNING MULTIPLE VARIALBES".
A more proper and robust but more verbose solution is to use try { ... } finally { ... }
:
try {
# Temporarily set/create $env:MYLANG to 'EN'
$prevVal = $env:MYLANG; $env:MYLANG = 'EN'
my-cmd dostuff -o out.csv # run the command(s) that should see $env:MYLANG as 'EN'
} finally { # remove / restore the temporary value
# Note: if $env:MYLANG didn't previously exist, $prevVal is $null,
# and assigning that back to $env:MYLANG *removes* it, as desired.
$env:MYLANG = $prevVal
}
Note, however, that if you only ever call external programs with the temporarily modified environment, there is no strict need for try
/ catch
, because external programs never cause PowerShell errors as of PowerShell 7.1, though that may change in the future.
To facilitate this approach, this answer to a related question offers convenience function
Invoke-WithEnvironment
, which allows you to write the same invocation as:
# Define env. var. $env:MYLANG only for the duration of executing the commands
# in { ... }
Invoke-WithEnvironment @{ MYLANG = 'EN' } { my-cmd dostuff -o out.csv }
By using an auxiliary process and only setting the transient environment variable there,
you avoid the need to restore the environment after invocation
but you pay a performance penalty, and invocation complexity is increased.
Using an aux. cmd.exe
process:
cmd /c "set `"MYLANG=EN`" & my-cmd dostuff -o out.csv"
Note:
Outer "..."
quoting was chosen so that you can reference PowerShell variables in your command; embedded "
must then be escaped as `"
Additionally, the arguments to the target command must be passed according to cmd.exe
's rules (makes no difference with the simple command at hand).
Using an aux. child PowerShell session:
# In PowerShell *Core*, use `pwsh` in lieu of `powershell`
powershell -nop -c { $env:MYLANG = 'EN'; my-cmd dostuff -o out.csv }
Note:
Starting another PowerShell session is expensive.
Output from the script block ({ ... }
) is subject to serialization and later deserialization in the calling scope; for string output, that doesn't matter, but complex objects such as [System.IO.FileInfo]
deserialize to emulations of the originals (which may or may not be problem).
There is a way to achieve this in PowerShell:
Local Scope:
& { [System.Environment]::SetEnvironmentVariable('TEST', 'WORK Local', [System.EnvironmentVariableTarget]::Process)
[System.Environment]::GetEnvironmentVariable("TEST", [System.EnvironmentVariableTarget]::Process) }
This creates the environmental variable in the scope of the process same as above. Any call to it outside the scope will return nothing.
For a global one you just change the target to Machine:
& { [System.Environment]::SetEnvironmentVariable('TEST', 'WORK Global', [System.EnvironmentVariableTarget]::Machine) }
Any call to this outside the scope will return 'Work Global'
Putting it all together:
## create local variable and print
& { [System.Environment]::SetEnvironmentVariable('TEST', 'WORK Local', [System.EnvironmentVariableTarget]::Process)
[System.Environment]::GetEnvironmentVariable("TEST", [System.EnvironmentVariableTarget]::Process) }
function tt {
($env:TEST)
}
& { $TEST="EN"; $env:TEST="EN"; tt }
& { $TEST="change1"; $env:TEST="change1"; tt }
& { $TEST="change1"; $env:TEST="change2"; tt }
[System.Environment]::GetEnvironmentVariable("TEST", [System.EnvironmentVariableTarget]::Process)
& { [System.Environment]::SetEnvironmentVariable('TEST', 'WORK Global', [System.EnvironmentVariableTarget]::Machine) } ## create global variable
## Create local variable and print ( overrides global )
& { [System.Environment]::SetEnvironmentVariable('TEST', 'WORK Local', [System.EnvironmentVariableTarget]::Process)
[System.Environment]::GetEnvironmentVariable("TEST", [System.EnvironmentVariableTarget]::Process) }
[System.Environment]::GetEnvironmentVariable("TEST", [System.EnvironmentVariableTarget]::Machine) ## get global variable
[System.Environment]::SetEnvironmentVariable("TEST",$null,"USer") ## remove global variable
This gives us the following output:
WORK Local
EN
change1
change2
change2
WORK Local
WORK Global
I would probably just use a try { } finally { }
:
try {
$OriginalValue = $env:MYLANG
$env:MYLANG= 'GB'
my-cmd dostuff -o out.csv
}
finally {
$env:MYLANG = $OriginalValue
}
That should force the values to be set back to their original values even if an error is encountered in your script. It's not bulletproof, but most things that would break this would also be very obvious that something went wrong.
You could also do this:
try {
$env:MYLANG= 'GB'
my-cmd dostuff -o out.csv
}
finally {
$env:MYLANG = [System.Environment]::GetEnvironmentVariable('MYLANG', 'User')
}
That should retrieve the value from HKEY_CURRENT_USER\Environment
. You may need 'Machine'
instead of 'User'
and that will pull from HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Session Manager\Environment
. Which you need depends if it's a user environment variable or a computer environment variable. This works because the Env:
provider drive doesn't persist environment variable changes, so changes to those variables won't change the registry.
Forgive me if I've missed something as there parts of this post I'm a bit unclear on.
I would use the scope modifies $local
, $script
and $global
modifiers.
Example
$env:TEST="EN"
function tt {
$local:env:TEST="GB"
($local:env:TEST)
}
$t = {
$local:env:TEST="DE"
($local:env:TEST)
}
($env:TEST)
tt
($env:TEST)
. $t
($env:TEST)
Output with comments
EN # ($env:TEST)
GB # tt
EN # ($env:TEST)
DE # . $t
EN # ($env:TEST)