Objective
Isolate environmental variable changes to a code block.
Background
If I want to create a batch script to run a command that requires an environmental variable set, I know I can do this:
setlocal
set MYLANG=EN
my-cmd dostuff -o out.csv
endlocal
However, I tend to use PowerShell when I need to using a shell scripting language. I know how to set the environmental variable ($env:TEST="EN"
) and of course this is just a simple example. However, I am not sure how to achieve the same effect that I could with a batch script. Surprisingly, I don't see any questions asking this either.
I am aware that setting something with $env:TEST="EN"
is process scoped, but that isn't practical if I'm using scripts as small utilities in a single terminal session.
My current approaches:
setlocal
. But that wasn't a commandlet... I hoped.$env:
seems to be not much unlike $global:
)Function scope doesn't trump the reference to $env:
$env:TEST="EN"
function tt {
$env:TEST="GB"
($env:TEST)
}
($env:TEST)
tt
($env:TEST)
Output:
C:\Users\me> .\eg.ps1
EN
GB
GB
Setlocal syntaxBegins localization of environment changes in a batch file. Environment changes made after SETLOCAL is issued are local to the batch file. ENDLOCAL must be issued to restore the previous settings.
ENABLEDELAYEDEXPANSION is a parameter passed to the SETLOCAL command (look at setlocal /? ) Its effect lives for the duration of the script, or an ENDLOCAL : When the end of a batch script is reached, an implied ENDLOCAL is executed for any outstanding SETLOCAL commands issued by that batch script.
Use setlocal to change environment variables when you run a batch file. Environment changes made after you run setlocal are local to the batch file. The Cmd.exe program restores previous settings when it encounters an endlocal command or reaches the end of the batch file.
Delayed Expansion will cause variables within a batch file to be expanded at execution time rather than at parse time, this option is turned on with the SETLOCAL EnableDelayedExpansion command. By default expansion will happen just once, before each line is executed.
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 functionInvoke-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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With