Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

What is the setlocal / endlocal equivalent for PowerShell?

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:

  1. Entered setlocal. But that wasn't a commandlet... I hoped.
  2. Save the current variable to a temp variable, run my command, change it back... kinda silly.
  3. Function level scope (though I doubted the success since $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
like image 671
Palu Macil Avatar asked Jan 24 '18 22:01

Palu Macil


People also ask

What is Setlocal and Endlocal in batch?

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.

What does Setlocal Enabledelayedexpansion mean?

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.

What is Setlocal in CMD?

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.

How do I use Setlocal Enabledelayedexpansion?

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.


3 Answers

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 = ...; ... }):

In-process approach: manual management of a custom scope:

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 }

Alternatives, using an auxiliary process:

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).

like image 184
mklement0 Avatar answered Oct 28 '22 00:10

mklement0


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
like image 29
Owain Esau Avatar answered Oct 27 '22 23:10

Owain Esau


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.

like image 26
Bacon Bits Avatar answered Oct 28 '22 01:10

Bacon Bits