9

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
Palu Macil
  • 1,708
  • 1
  • 24
  • 34
  • 1
    I don't think PowerShell has an equivalent for `setlocal`, so #2 is probably your best bet. [Related](https://stackoverflow.com/q/1420719/1630171). – Ansgar Wiechers Jan 24 '18 at 22:55
  • i think you probably want Set-Variable: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/set-variable?view=powershell-5.1 – Owain Esau Jan 24 '18 at 22:55
  • For others: In a deleted answer, Owain and I agreed that Set-Variable doesn't accomplish what I asked. – Palu Macil Jan 25 '18 at 03:42

4 Answers4

8

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

mklement0
  • 382,024
  • 64
  • 607
  • 775
1

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
Owain Esau
  • 1,876
  • 2
  • 21
  • 34
  • This is somewhat more complex than my #2 workaround, haha, but I suppose I'll mark you correct if someone doesn't pop out a short magical one liner or so in the next day. – Palu Macil Jan 25 '18 at 03:37
  • It is a bit more complex but its the only way I could find to do it. Can always just create a function out of it and import when you need. – Owain Esau Jan 25 '18 at 06:53
  • 1
    Setting a _process-level_ environment variable, as the name implies, sets it for the entire process = PS session = (session-) _global_; using `& { ... }` does _not_ change that. As an aside: It is _process-level_ environment variables that PowerShell sets / accesses via its `$env:` prefix, so for that there's no need to for the more cumbersome use of `[System.Environment]`. – mklement0 Jan 25 '18 at 14:31
  • 1
    If you set a _machine-level_ environment variable with `[System.Environment]::SetEnvironmentVariable(..., ..., 'Machine'`, you (a) set it _persistently_, for _all_ future processes, which is not the intent. To make matter worse, it is (b) actually _not_ being set for the _current_ process and its _child processes_. Also, the caller (c) needs _elevation_ in order to set machine-level env. variables. – mklement0 Jan 25 '18 at 14:31
1

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.

Bacon Bits
  • 30,782
  • 5
  • 59
  • 66
  • 1
    Indeed, but the 1st solution is already part of my answer. The 2nd solution is problematic in that you cannot blindly restore the default (effective) process-level value, because previous code in the current PS session may have modified it too, in which case you want to restore _that_ value. – mklement0 Jan 25 '18 at 16:11
-2

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)
G42
  • 9,791
  • 2
  • 19
  • 34
  • 2
    The point is setting *environment* variables with a particular scope; that is to say, external commands should see `TEST = GB` when invoked from that scope. `$local:env:TEST` does not set any environment variable, it simply sets a local variable with the unusual name `env:TEST` (which you can see if you do `Get-Variable`). – Jeroen Mostert Jan 25 '18 at 10:34