diff --git a/conan/internal/api/install/generators.py b/conan/internal/api/install/generators.py index 0ee676a8133..87865d8f9fd 100644 --- a/conan/internal/api/install/generators.py +++ b/conan/internal/api/install/generators.py @@ -182,6 +182,11 @@ def deactivates(filenames): result.append(os.path.join(folder, "deactivate_{}".format(f))) return result + def deactivate_function_names(filenames): + return [os.path.splitext(os.path.basename(s))[0].replace("-", "_") + for s in reversed(filenames)] + + deactivation_mode = conanfile.conf.get("tools.env:deactivation_mode", default=None, check_type=str) generated = [] for group, env_scripts in conanfile.env_scripts.items(): subsystem = deduce_subsystem(conanfile, group) @@ -202,12 +207,19 @@ def deactivates(filenames): ps1s.append("$PSScriptRoot/"+path) if shs: def sh_content(files): - return ". " + " && . ".join('"{}"'.format(s) for s in files) + content = ". " + " && . ".join('"{}"'.format(s) for s in files) + if deactivation_mode == "function": + content += f"\n\ndeactivate_conan{group}() {{\n" + for deactivate_name in deactivate_function_names(shs): + content += f" deactivate_{deactivate_name}\n" + content += f" unset -f deactivate_conan{group}\n}}\n" + return content filename = "conan{}.sh".format(group) generated.append(filename) save(os.path.join(conanfile.generators_folder, filename), sh_content(shs)) - save(os.path.join(conanfile.generators_folder, "deactivate_{}".format(filename)), - sh_content(deactivates(shs))) + if not deactivation_mode: + save(os.path.join(conanfile.generators_folder, "deactivate_{}".format(filename)), + sh_content(deactivates(shs))) if bats: def bat_content(files): return "\r\n".join(["@echo off"] + ['call "{}"'.format(b) for b in files]) @@ -218,12 +230,21 @@ def bat_content(files): bat_content(deactivates(bats))) if ps1s: def ps1_content(files): - return "\r\n".join(['& "{}"'.format(b) for b in files]) + content = "\r\n".join(['& "{}"'.format(b) for b in files]) + if deactivation_mode == "function": + content += f"\n\nfunction global:deactivate_conan{group} {{\n" + for deactivate_name in deactivate_function_names(ps1s): + content += f" deactivate_{deactivate_name}\n" + content += (f" Remove-Item -Path function:deactivate_conan{group} " + "-ErrorAction SilentlyContinue" + "\n}\n") + return content filename = "conan{}.ps1".format(group) generated.append(filename) save(os.path.join(conanfile.generators_folder, filename), ps1_content(ps1s)) - save(os.path.join(conanfile.generators_folder, "deactivate_{}".format(filename)), - ps1_content(deactivates(ps1s))) + if not deactivation_mode: + save(os.path.join(conanfile.generators_folder, "deactivate_{}".format(filename)), + ps1_content(deactivates(ps1s))) if generated: conanfile.output.highlight("Generating aggregated env files") conanfile.output.info(f"Generated aggregated env files: {generated}") diff --git a/conan/internal/model/conf.py b/conan/internal/model/conf.py index 98c58bb5e35..fcf11ad2748 100644 --- a/conan/internal/model/conf.py +++ b/conan/internal/model/conf.py @@ -135,6 +135,7 @@ "tools.apple:enable_visibility": "(boolean) Enable/Disable Visibility Apple Clang flags", "tools.env.virtualenv:powershell": "If specified, it generates PowerShell launchers (.ps1). Use this configuration setting the PowerShell executable you want to use (e.g., 'powershell.exe' or 'pwsh'). Setting it to True or False is deprecated as of Conan 2.11.0.", "tools.env:dotenv": "(Experimental) Generate dotenv environment files", + "tools.env:deactivation_mode": "(Experimental) If 'function', generate a deactivate function instead of a script to unset the environment variables", # Compilers/Flags configurations "tools.build:compiler_executables": "Defines a Python dict-like with the compilers path to be used. Allowed keys {'c', 'cpp', 'cuda', 'objc', 'objcxx', 'rc', 'fortran', 'asm', 'hip', 'ispc'}", "tools.build:cxxflags": "List of extra CXX flags used by different toolchains like CMakeToolchain, AutotoolsToolchain and MesonToolchain", diff --git a/conan/tools/env/environment.py b/conan/tools/env/environment.py index 09d69f6e675..676950b794d 100644 --- a/conan/tools/env/environment.py +++ b/conan/tools/env/environment.py @@ -1,5 +1,6 @@ import os import textwrap +from shlex import quote from collections import OrderedDict from contextlib import contextmanager @@ -345,6 +346,7 @@ def __init__(self, conanfile, values, scope): self._conanfile = conanfile self._scope = scope self._subsystem = deduce_subsystem(conanfile, scope) + self._deactivation_mode = conanfile.conf.get("tools.env:deactivation_mode", default=None, check_type=str) @property def _pathsep(self): @@ -450,44 +452,24 @@ def save_bat(self, file_location, generate_deactivate=True): with open(file_location, "w", encoding="utf-8") as f: f.write(content) - def save_ps1(self, file_location, generate_deactivate=True,): + def save_ps1(self, file_location, generate_deactivate=True): _, filename = os.path.split(file_location) - deactivate_file = "deactivate_{}".format(filename) - deactivate = textwrap.dedent("""\ - Push-Location $PSScriptRoot - "echo `"Restoring environment`"" | Out-File -FilePath "{deactivate_file}" - $vars = (Get-ChildItem env:*).name - $updated_vars = @({vars}) - - foreach ($var in $updated_vars) - {{ - if ($var -in $vars) - {{ - $var_value = (Get-ChildItem env:$var).value - Add-Content "{deactivate_file}" "`n`$env:$var = `"$var_value`"" - }} - else - {{ - Add-Content "{deactivate_file}" "`nif (Test-Path env:$var) {{ Remove-Item env:$var }}" - }} - }} - Pop-Location - """).format( - deactivate_file=deactivate_file, - vars=",".join(['"{}"'.format(var) for var in self._values.keys()]) - ) - capture = textwrap.dedent("""\ - {deactivate} - """).format(deactivate=deactivate if generate_deactivate else "") - result = [capture] + result = [] + if generate_deactivate: + result.append(_ps1_deactivate_contents(self._deactivation_mode, self._values, filename)) abs_base_path, new_path = relativize_paths(self._conanfile, "$PSScriptRoot") for varname, varvalues in self._values.items(): value = varvalues.get_str("$env:{name}", subsystem=self._subsystem, pathsep=self._pathsep, root_path=abs_base_path, script_path=new_path) + if generate_deactivate and self._deactivation_mode == "function": + # Check environment variable existence before saving value + result.append( + f'if ($env:{varname}) {{ $env:{_old_env_prefix(filename)}_{varname} = $env:{varname} }}' + ) if value: value = value.replace('"', '`"') # escape quotes - result.append('$env:{}="{}"'.format(varname, value)) + result.append(f'$env:{varname}="{value}"') else: result.append('if (Test-Path env:{0}) {{ Remove-Item env:{0} }}'.format(varname)) @@ -500,34 +482,25 @@ def save_ps1(self, file_location, generate_deactivate=True,): def save_sh(self, file_location, generate_deactivate=True): filepath, filename = os.path.split(file_location) - deactivate_file = os.path.join("$script_folder", "deactivate_{}".format(filename)) - deactivate = textwrap.dedent("""\ - echo "echo Restoring environment" > "{deactivate_file}" - for v in {vars} - do - is_defined="true" - value=$(printenv $v) || is_defined="" || true - if [ -n "$value" ] || [ -n "$is_defined" ] - then - echo export "$v='$value'" >> "{deactivate_file}" - else - echo unset $v >> "{deactivate_file}" - fi - done - """.format(deactivate_file=deactivate_file, vars=" ".join(self._values.keys()))) - capture = textwrap.dedent("""\ - {deactivate} - """).format(deactivate=deactivate if generate_deactivate else "") - result = [capture] + result = [] + if generate_deactivate: + result.append(_sh_deactivate_contents(self._deactivation_mode, self._values, filename)) abs_base_path, new_path = relativize_paths(self._conanfile, "$script_folder") for varname, varvalues in self._values.items(): value = varvalues.get_str("${name}", self._subsystem, pathsep=self._pathsep, root_path=abs_base_path, script_path=new_path) value = value.replace('"', '\\"') + if generate_deactivate and self._deactivation_mode == "function": + # Check environment variable existence before saving value + result.append( + f'if [ -n "${{{varname}+x}}" ]; then ' + f'export {_old_env_prefix(filename)}_{varname}="${{{varname}}}"; ' + f'fi;' + ) if value: - result.append('export {}="{}"'.format(varname, value)) + result.append(f'export {varname}="{value}"') else: - result.append('unset {}'.format(varname)) + result.append(f'unset {varname}') content = "\n".join(result) content = f'script_folder="{os.path.abspath(filepath)}"\n' + content @@ -592,6 +565,98 @@ def save_script(self, filename): register_env_script(self._conanfile, path, self._scope) +def _deactivate_func_name(filename): + return os.path.splitext(os.path.basename(filename))[0].replace("-", "_") + + +def _old_env_prefix(filename): + return f"_CONAN_OLD_{_deactivate_func_name(filename).upper()}" + + +def _ps1_deactivate_contents(deactivation_mode, values, filename): + vars_list = ", ".join(f'"{v}"' for v in values.keys()) + if deactivation_mode == "function": + var_prefix = _old_env_prefix(filename) + func_name = _deactivate_func_name(filename) + return textwrap.dedent(f"""\ + function global:deactivate_{func_name} {{ + Write-Host "Restoring environment" + foreach ($v in @({vars_list})) {{ + $oldVarName = "{var_prefix}_$v" + $oldValue = Get-Item -Path "Env:$oldVarName" -ErrorAction SilentlyContinue + if (Test-Path env:$oldValue) {{ + Remove-Item -Path "Env:$v" -ErrorAction SilentlyContinue + }} else {{ + Set-Item -Path "Env:$v" -Value $oldValue.Value + }} + Remove-Item -Path "Env:$oldVarName" -ErrorAction SilentlyContinue + }} + Remove-Item -Path function:deactivate_{func_name} -ErrorAction SilentlyContinue + }} + """) + + deactivate_file = "deactivate_{}".format(filename) + return textwrap.dedent(f"""\ + Push-Location $PSScriptRoot + "echo `"Restoring environment`"" | Out-File -FilePath "{deactivate_file}" + $vars = (Get-ChildItem env:*).name + $updated_vars = @({vars_list}) + + foreach ($var in $updated_vars) + {{ + if ($var -in $vars) + {{ + $var_value = (Get-ChildItem env:$var).value + Add-Content "{deactivate_file}" "`n`$env:$var = `"$var_value`"" + }} + else + {{ + Add-Content "{deactivate_file}" "`nif (Test-Path env:$var) {{ Remove-Item env:$var }}" + }} + }} + Pop-Location + """) + +def _sh_deactivate_contents(deactivation_mode, values, filename): + vars_list = " ".join(quote(v) for v in values.keys()) + if deactivation_mode == "function": + func_name = _deactivate_func_name(filename) + return textwrap.dedent(f"""\ + # sh-like function to restore environment + deactivate_{func_name} () {{ + echo "Restoring environment" + for v in {vars_list}; do + old_var="{_old_env_prefix(filename)}_${{v}}" + # Use eval for indirect expansion (POSIX safe) + eval "is_set=\\${{${{old_var}}+x}}" + if [ -n "${{is_set}}" ]; then + eval "old_value=\\${{${{old_var}}}}" + eval "export ${{v}}=\\${{old_value}}" + else + unset "${{v}}" + fi + unset "${{old_var}}" + done + unset -f deactivate_{func_name} + }} + """) + deactivate_file = os.path.join("$script_folder", "deactivate_{}".format(filename)) + return textwrap.dedent(f"""\ + echo "echo Restoring environment" > "{deactivate_file}" + for v in {vars_list} + do + is_defined="true" + value=$(printenv $v) || is_defined="" || true + if [ -n "$value" ] || [ -n "$is_defined" ] + then + echo export "$v='$value'" >> "{deactivate_file}" + else + echo unset $v >> "{deactivate_file}" + fi + done + """) + + class ProfileEnvironment: def __init__(self): self._environments = OrderedDict() diff --git a/conan/tools/microsoft/visual.py b/conan/tools/microsoft/visual.py index c38d9d13636..2a7d490f114 100644 --- a/conan/tools/microsoft/visual.py +++ b/conan/tools/microsoft/visual.py @@ -199,7 +199,7 @@ def generate(self, scope="build"): def _create_deactivate_vcvars_file(conanfile, filename): deactivate_filename = f"deactivate_{filename}" - message = f"[{deactivate_filename}]: vcvars env cannot be deactivated" + message = f"[{deactivate_filename}]: *** vcvars env cannot be deactivated ***\n" is_ps1 = filename.endswith(".ps1") if is_ps1: content = f"Write-Host {message}" diff --git a/test/integration/environment/test_env.py b/test/integration/environment/test_env.py index 92539cacb87..945352597fa 100644 --- a/test/integration/environment/test_env.py +++ b/test/integration/environment/test_env.py @@ -486,7 +486,8 @@ class Pkg(ConanFile): assert "LD_LIBRARY_PATH" in conanrunenv -def test_multiple_deactivate(): +@pytest.mark.parametrize("deactivation_mode", ["function", None]) +def test_multiple_deactivate(deactivation_mode): conanfile = textwrap.dedent(r""" from conan import ConanFile from conan.tools.env import Environment @@ -513,13 +514,14 @@ def generate(self): "display.bat": display_bat, "display.sh": display_sh}) os.chmod(os.path.join(client.current_folder, "display.sh"), 0o777) - client.run("install .") + client.run(f"install . {f'-c=tools.env:deactivation_mode={deactivation_mode}' if deactivation_mode else ''} ") for _ in range(2): # Just repeat it, so we can check things keep working if platform.system() == "Windows": cmd = "conanbuild.bat && display.bat && deactivate_conanbuild.bat && display.bat" else: - cmd = '. ./conanbuild.sh && ./display.sh && . ./deactivate_conanbuild.sh && ./display.sh' + deactivate_cmd = "deactivate_conanbuild" if deactivation_mode else ". ./deactivate_conanbuild.sh" + cmd = f'. ./conanbuild.sh && ./display.sh && {deactivate_cmd} && ./display.sh' out, _ = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True, cwd=client.current_folder).communicate() out = out.decode() @@ -530,7 +532,8 @@ def generate(self): assert "VAR2=!!" in out -def test_multiple_deactivate_order(): +@pytest.mark.parametrize("deactivation_mode", ["function", None]) +def test_multiple_deactivate_order(deactivation_mode): """ https://github.com/conan-io/conan/issues/13693 """ @@ -558,13 +561,14 @@ def generate(self): "display.bat": display_bat, "display.sh": display_sh}) os.chmod(os.path.join(client.current_folder, "display.sh"), 0o777) - client.run("install .") + client.run(f"install . {f'-c=tools.env:deactivation_mode={deactivation_mode}' if deactivation_mode else ''} ") for _ in range(2): # Just repeat it, so we can check things keep working if platform.system() == "Windows": cmd = "conanbuild.bat && display.bat && deactivate_conanbuild.bat && display.bat" else: - cmd = '. ./conanbuild.sh && ./display.sh && . ./deactivate_conanbuild.sh && ./display.sh' + deactivate_cmd = "deactivate_conanbuild" if deactivation_mode else ". ./deactivate_conanbuild.sh" + cmd = f'. ./conanbuild.sh && ./display.sh && {deactivate_cmd} && ./display.sh' out, _ = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True, cwd=client.current_folder).communicate() out = out.decode() @@ -573,6 +577,70 @@ def generate(self): assert "MYVAR=!!" in out +@pytest.mark.skipif(platform.system() == "Windows", reason="Shell script test") +@pytest.mark.parametrize("deactivation_mode", ["function", None]) +def test_deactivate_missing_vars_stay_missing(deactivation_mode): + """ Tests that these two cases preserve variable status + 1. + export FOO= + ./conanrunenv.sh that changes FOO to something with a value + ./deactivate_conanrunenv.sh + FOO is still empty + + 2. + BAR is not defined + ./conanrunenv.sh that changes BAR to something with a value + ./deactivate_conanrunenv.sh + BAR is still not defined + + 3. + BAZ is defined to some value + ./conanrunenv.sh that unsets BAZ + ./deactivate_conanrunenv.sh + BAZ is still defined to some value + + 4. + FOOBAR is empty + ./conanrunenv.sh that unsets FOOBAR + ./deactivate_conanrunenv.sh + FOOBAR is still empty + """ + conanfile = textwrap.dedent(r""" + from conan import ConanFile + from conan.tools.env import Environment + class Pkg(ConanFile): + def generate(self): + e1 = Environment() + e1.define("FOO", "Value1") + e1.define("BAR", "Value2") + e1.unset("BAZ") + e1.unset("FOOBAR") + e1.vars(self).save_script("mybuild1") + """) + display_sh = textwrap.dedent("""\ + echo FOO=$FOO!! + if [ -n "${BAR+x}" ]; then echo "BAR EXISTS!!"; fi; + echo BAZ=$BAZ!! + echo FOOBAR=$FOOBAR!! + """) + client = TestClient(light=True) + client.save({"conanfile.py": conanfile, + "display.sh": display_sh}) + os.chmod(os.path.join(client.current_folder, "display.sh"), 0o777) + client.run(f"install . {f'-c=tools.env:deactivation_mode={deactivation_mode}' if deactivation_mode else ''} ") + + deactivate_cmd = "deactivate_conanbuild" if deactivation_mode else ". ./deactivate_conanbuild.sh" + cmd = (f'export FOO=&& export BAZ=Value3 && export FOOBAR=' + f'&& . ./conanbuild.sh && {deactivate_cmd} && ./display.sh') + out, _ = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + shell=True, cwd=client.current_folder).communicate() + out = out.decode() + assert "FOO=!!" in out + assert "BAR EXISTS!!" not in out + assert "BAZ=Value3!!" in out + assert "FOOBAR=!!" in out + + @pytest.mark.skipif(platform.system() != "Windows", reason="Path problem in Windows only") @pytest.mark.parametrize("num_deps", [3, ]) def test_massive_paths(num_deps): @@ -645,7 +713,8 @@ class Pkg(ConanFile): assert "MYTOOL {}!!".format(i) in client.out -def test_profile_build_env_spaces(): +@pytest.mark.parametrize("deactivation_mode", ["function", None]) +def test_profile_build_env_spaces(deactivation_mode): display_bat = textwrap.dedent("""\ @echo off echo VAR1=%VAR1%!! @@ -659,12 +728,13 @@ def test_profile_build_env_spaces(): "display.bat": display_bat, "display.sh": display_sh}) os.chmod(os.path.join(client.current_folder, "display.sh"), 0o777) - client.run("install . -g VirtualBuildEnv -pr=profile") + client.run(f"install . -g VirtualBuildEnv -pr=profile {f'-c=tools.env:deactivation_mode={deactivation_mode}' if deactivation_mode else ''} ") if platform.system() == "Windows": cmd = "conanbuild.bat && display.bat && deactivate_conanbuild.bat && display.bat" else: - cmd = '. ./conanbuild.sh && ./display.sh && . ./deactivate_conanbuild.sh && ./display.sh' + deactivate_cmd = "deactivate_conanbuild" if deactivation_mode else ". ./deactivate_conanbuild.sh" + cmd = f'. ./conanbuild.sh && ./display.sh && {deactivate_cmd} && ./display.sh' out, _ = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True, cwd=client.current_folder).communicate() out = out.decode() diff --git a/test/unittests/tools/env/test_env.py b/test/unittests/tools/env/test_env.py index 53411087eb2..282a5f40831 100644 --- a/test/unittests/tools/env/test_env.py +++ b/test/unittests/tools/env/test_env.py @@ -232,7 +232,8 @@ def test_windows_case_insensitive_bat(envvars): @pytest.mark.skipif(platform.system() != "Windows", reason="Requires Windows") -def test_windows_case_insensitive_ps1(envvars): +@pytest.mark.parametrize("deactivation_mode", ["function", None],) +def test_windows_case_insensitive_ps1(envvars, deactivation_mode): display = textwrap.dedent("""\ echo "MyVar=$env:MyVar!!" echo "MyVar1=$env:MyVar1!!" @@ -245,9 +246,11 @@ def test_windows_case_insensitive_ps1(envvars): prevenv.update(dict(os.environ.copy())) with chdir(temp_folder()): + envvars._deactivation_mode = deactivation_mode envvars.save_ps1("test.ps1") save("display.ps1", display) - cmd = "powershell.exe .\\test.ps1 ; .\\display.ps1 ; .\\deactivate_test.ps1 ; .\\display.ps1" + deactivate_cmd = "deactivate_test" if deactivation_mode else ".\\deactivate_test.ps1" + cmd = f"powershell.exe .\\test.ps1 ; .\\display.ps1 ; {deactivate_cmd} ; .\\display.ps1" check_command_output(cmd, prevenv) diff --git a/test/unittests/tools/env/test_env_files.py b/test/unittests/tools/env/test_env_files.py index 852ae42317a..9218910f1d2 100644 --- a/test/unittests/tools/env/test_env_files.py +++ b/test/unittests/tools/env/test_env_files.py @@ -103,7 +103,8 @@ def test_env_files_bat(env, prevenv): @pytest.mark.skipif(platform.system() != "Windows", reason="Requires Windows") -def test_env_files_ps1(env, prevenv): +@pytest.mark.parametrize("deactivation_mode", ["function", None]) +def test_env_files_ps1(env, prevenv, deactivation_mode): prevenv.update(dict(os.environ.copy())) display = textwrap.dedent("""\ @@ -121,16 +122,21 @@ def test_env_files_ps1(env, prevenv): """) with chdir(temp_folder()): - env = env.vars(ConanFileMock()) + conanfile = ConanFileMock() + if deactivation_mode: + conanfile.conf.define("tools.env:deactivation_mode", deactivation_mode) + env = env.vars(conanfile) env._subsystem = WINDOWS env.save_ps1("test.ps1") save("display.ps1", display) - cmd = "powershell.exe .\\test.ps1 ; .\\display.ps1 ; .\\deactivate_test.ps1 ; .\\display.ps1" + deactivate_cmd = "deactivate_test" if deactivation_mode else ".\\deactivate_test.ps1" + cmd = f"powershell.exe .\\test.ps1 ; .\\display.ps1 ; {deactivate_cmd} ; .\\display.ps1" check_env_files_output(cmd, prevenv) @pytest.mark.skipif(platform.system() == "Windows", reason="Not in Windows") -def test_env_files_sh(env, prevenv): +@pytest.mark.parametrize("deactivation_mode", ["function", None]) +def test_env_files_sh(env, prevenv, deactivation_mode): display = textwrap.dedent("""\ echo MyVar=$MyVar!! echo MyVar1=$MyVar1!! @@ -146,12 +152,16 @@ def test_env_files_sh(env, prevenv): """) with chdir(temp_folder()): - env = env.vars(ConanFileMock()) + conanfile = ConanFileMock() + if deactivation_mode: + conanfile.conf.define("tools.env:deactivation_mode", deactivation_mode) + env = env.vars(conanfile) env.save_sh("test.sh") save("display.sh", display) os.chmod("display.sh", 0o777) # We include the "set -e" to test it is robust against errors - cmd = 'set -e && . ./test.sh && ./display.sh && . ./deactivate_test.sh && ./display.sh' + deactivate_cmd = "deactivate_test" if deactivation_mode else ". ./deactivate_test.sh" + cmd = f'set -e && . ./test.sh && ./display.sh && {deactivate_cmd} && ./display.sh' check_env_files_output(cmd, prevenv)