diff --git a/conan/tools/env/environment.py b/conan/tools/env/environment.py index ca5d98685fe..fbe23c3203e 100644 --- a/conan/tools/env/environment.py +++ b/conan/tools/env/environment.py @@ -1,7 +1,12 @@ import os import textwrap +import random +import string +from jinja2 import Template from collections import OrderedDict from contextlib import contextmanager +from pathlib import Path + from conans.client.generators import relativize_paths from conans.client.subsystems import deduce_subsystem, WINDOWS, subsystem_path @@ -19,16 +24,19 @@ def environment_wrap_command(env_filenames, env_folder, cmd, subsystem=None, if not env_filenames: return cmd filenames = [env_filenames] if not isinstance(env_filenames, list) else env_filenames - bats, shs, ps1s = [], [], [] + bats, shs, ps1s, fishs = [], [], [], [] - accept = accepted_extensions or ("ps1", "bat", "sh") - # TODO: This implemantation is dirty, improve it + accept = accepted_extensions or ("ps1", "bat", "sh", "fish") + # TODO: This implementation is dirty, improve it for f in filenames: f = f if os.path.isabs(f) else os.path.join(env_folder, f) if f.lower().endswith(".sh"): if os.path.isfile(f) and "sh" in accept: f = subsystem_path(subsystem, f) shs.append(f) + elif f.lower().endswith(".fish"): + if os.path.isfile(f) and "fish" in accept: + fishs.append(f) elif f.lower().endswith(".bat"): if os.path.isfile(f) and "bat" in accept: bats.append(f) @@ -39,6 +47,7 @@ def environment_wrap_command(env_filenames, env_folder, cmd, subsystem=None, path_bat = "{}.bat".format(f) path_sh = "{}.sh".format(f) path_ps1 = "{}.ps1".format(f) + path_fish = "{}.fish".format(f) if os.path.isfile(path_bat) and "bat" in accept: bats.append(path_bat) if os.path.isfile(path_ps1) and "ps1" in accept: @@ -46,10 +55,12 @@ def environment_wrap_command(env_filenames, env_folder, cmd, subsystem=None, if os.path.isfile(path_sh) and "sh" in accept: path_sh = subsystem_path(subsystem, path_sh) shs.append(path_sh) + if os.path.isfile(path_fish) and "fish" in accept: + fishs.append(path_fish) - if bool(bats + ps1s) + bool(shs) > 1: + if bool(bats + ps1s) + bool(shs) > 1 + bool(fishs) > 1: raise ConanException("Cannot wrap command with different envs," - "{} - {}".format(bats+ps1s, shs)) + "{} - {} - {}".format(bats+ps1s, shs, fishs)) if bats: launchers = " && ".join('"{}"'.format(b) for b in bats) @@ -62,6 +73,9 @@ def environment_wrap_command(env_filenames, env_folder, cmd, subsystem=None, elif shs: launchers = " && ".join('. "{}"'.format(f) for f in shs) return '{} && {}'.format(launchers, cmd) + elif fishs: + launchers = " && ".join('. \\"{}\\"'.format(f) for f in fishs) + return 'fish -c "{}; and {}"'.format(launchers, cmd) elif ps1s: # TODO: at the moment it only works with path without spaces launchers = " ; ".join('"&\'{}\'"'.format(f) for f in ps1s) @@ -520,12 +534,76 @@ def save_sh(self, file_location, generate_deactivate=True): content = f'script_folder="{os.path.abspath(filepath)}"\n' + content save(file_location, content) + def save_fish(self, file_location): + """Save a fish script file with the environment variables defined in the Environment object. + + It generates a function to deactivate the environment variables configured in the Environment object. + + TODO: Honor append and prepend paths from recipe to the fish shell syntax. + buildenv_info.append_path should be fish set -a + buildenv_info.prepend_path should be fish set -p + buildenv_info.define_path should be fish set + + :param file_location: The path to the file to save the fish script. + """ + filepath, filename = os.path.split(file_location) + function_name = f"deactivate_{Path(filename).stem}" + group = "".join(random.choices(string.ascii_letters + string.digits, k=8)) + script_content = Template(textwrap.dedent(""" + function remove_path + set -l variable_name $argv[1] + set -l to_remove $argv[2] + if set -l index (contains -i $to_remove $$variable_name) + set -e {$variable_name}[$index] + end + end + function {{ function_name }} + echo "Restoring environment" + for var in $conan_{{ group }}_del + set -e $var + end + set -e conan_{{ group }}_del + {% for item in vars_define.keys() %} + if set -q conan_{{ group }}_{{ item }} + remove_path {{ item }} $conan_{{ group }}_{{ item }} + set -e conan_{{ group }}_{{ item }} + end + {% endfor %} + end + + {% for item, value in vars_define.items() %} + if not set -q {{ item }} + set -ga conan_{{ group }}_del {{ item }} + set -gx {{ item }} "{{ value }}" + else + set -g conan_{{ group }}_{{ item }} "{{ value }}" + set -pgx {{ item }} "{{ value }}" + end + {% endfor %} + exit 0 + """)) + values = self._values.keys() + vars_define = {} + for varname, varvalues in self._values.items(): + abs_base_path, new_path = relativize_paths(self._conanfile, "$script_folder") + value = varvalues.get_str("", self._subsystem, pathsep=self._pathsep, + root_path=abs_base_path, script_path=new_path) + value = value.replace('"', '\\"') + vars_define[varname] = value + + if values: + content = script_content.render(function_name=function_name, group=group, + vars_define=vars_define) + save(file_location, content) + def save_script(self, filename): """ Saves a script file (bat, sh, ps1) with a launcher to set the environment. If the conf "tools.env.virtualenv:powershell" is set to True it will generate powershell launchers if Windows. + If the conf "tools.env.virtualenv:fish" is set to True it will generate fish launchers. + :param filename: Name of the file to generate. If the extension is provided, it will generate the launcher script for that extension, otherwise the format will be deduced checking if we are running inside Windows (checking also the subsystem) or not. @@ -534,18 +612,27 @@ def save_script(self, filename): if ext: is_bat = ext == ".bat" is_ps1 = ext == ".ps1" + is_fish = ext == ".fish" else: # Need to deduce it automatically is_bat = self._subsystem == WINDOWS is_ps1 = self._conanfile.conf.get("tools.env.virtualenv:powershell", check_type=bool) - if is_ps1: + is_fish = self._conanfile.conf.get("tools.env.virtualenv:fish", check_type=bool) + if is_fish: + filename = filename + ".fish" + is_bat = False + is_ps1 = False + elif is_ps1: filename = filename + ".ps1" is_bat = False + is_fish = False else: filename = filename + (".bat" if is_bat else ".sh") path = os.path.join(self._conanfile.generators_folder, filename) if is_bat: self.save_bat(path) + elif is_fish: + self.save_fish(path) elif is_ps1: self.save_ps1(path) else: diff --git a/conans/client/generators/__init__.py b/conans/client/generators/__init__.py index de0d06fd146..0c842cae6bb 100644 --- a/conans/client/generators/__init__.py +++ b/conans/client/generators/__init__.py @@ -166,6 +166,7 @@ def deactivates(filenames): bats = [] shs = [] ps1s = [] + fishs = [] for env_script in env_scripts: path = os.path.join(conanfile.generators_folder, env_script) # Only the .bat and .ps1 are made relative to current script @@ -174,6 +175,9 @@ def deactivates(filenames): bats.append("%~dp0/"+path) elif env_script.endswith(".sh"): shs.append(subsystem_path(subsystem, path)) + elif env_script.endswith(".fish"): + path = os.path.abspath(path) + fishs.append(path) elif env_script.endswith(".ps1"): path = os.path.relpath(path, conanfile.generators_folder) # This $PSScriptRoot uses the current script directory @@ -186,6 +190,14 @@ def sh_content(files): 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 fishs: + def fish_content(files): + return ". " + " && . ".join('"{}"'.format(s) for s in files) + fishs = [it for it in fishs if os.path.exists(it)] + if fishs: + filename = "conan{}.fish".format(group) + generated.append(filename) + save(os.path.join(conanfile.generators_folder, filename), fish_content(fishs)) if bats: def bat_content(files): return "\r\n".join(["@echo off"] + ['call "{}"'.format(b) for b in files]) diff --git a/conans/model/conf.py b/conans/model/conf.py index e110378455e..40ef4e7de6d 100644 --- a/conans/model/conf.py +++ b/conans/model/conf.py @@ -114,6 +114,7 @@ "tools.apple:enable_arc": "(boolean) Enable/Disable ARC Apple Clang flags", "tools.apple:enable_visibility": "(boolean) Enable/Disable Visibility Apple Clang flags", "tools.env.virtualenv:powershell": "If it is set to True it will generate powershell launchers if os=Windows", + "tools.env.virtualenv:fish": "If it is set to True it will generate fish launchers", # 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', 'ld', 'ar'}", "tools.build:cxxflags": "List of extra CXX flags used by different toolchains like CMakeToolchain, AutotoolsToolchain and MesonToolchain", diff --git a/test/conftest.py b/test/conftest.py index bc148aabefd..ae074bda698 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -181,6 +181,10 @@ "path": {'Darwin': f'{homebrew_root}/share/android-ndk'} } }, + 'fish': { + "default": "3.6", + "3.6": {"path": {"Darwin": f"{homebrew_root}/bin"}} + }, "qbs": {"disabled": True}, # TODO: Intel oneAPI is not installed in CI yet. Uncomment this line whenever it's done. # "intel_oneapi": { diff --git a/test/functional/toolchains/env/test_virtualenv_fish.py b/test/functional/toolchains/env/test_virtualenv_fish.py new file mode 100644 index 00000000000..cc20698be81 --- /dev/null +++ b/test/functional/toolchains/env/test_virtualenv_fish.py @@ -0,0 +1,208 @@ +import os +import glob +import platform + +import pytest + +from conan.test.assets.genconanfile import GenConanfile +from conan.test.utils.test_files import temp_folder +from conan.test.utils.tools import TestClient, load +from conans.util.files import save + +# INFO: Fish only supports Cygwin and WSL https://github.com/fish-shell/fish-shell?tab=readme-ov-file#windows +pytestmark = pytest.mark.skipif(platform.system() not in ("Darwin", "Linux"), reason="Fish is well supported only in Linux and MacOS") + + +@pytest.mark.tool("fish") +@pytest.mark.parametrize("fake_path", ["/path/to/fake/folder", "/path/with space/to/folder"]) +@pytest.mark.parametrize("my_var1", ["myvalue", "my value"]) +@pytest.mark.parametrize("my_var2", ["varwithspaces", "var with spaces"]) +@pytest.mark.parametrize("my_path", ["/path/to/ar", "/path/to space/ar"]) +def test_buildenv_define_new_vars(fake_path, my_var1, my_var2, my_path): + """Test when defining new path and new variable in buildenv_info. + + Variables should be available as environment variables in the buildenv script. + And, should be deleted when deactivate_conanbuildenv is called. + + Variable with spaces should be wrapped by single quotes when exported. + """ + cache_folder = os.path.join(temp_folder(), "[sub] folder") + client = TestClient(cache_folder) + conanfile = str(GenConanfile("pkg", "0.1")) + conanfile += f""" + + def package_info(self): + self.buildenv_info.define_path("MYPATH1", "{my_path}") + self.buildenv_info.define("MYVAR1", "{my_var1}") + self.buildenv_info.define("MYVAR2", "{my_var2}") + self.buildenv_info.prepend_path("PATH", "{fake_path}") + """ + client.save({"conanfile.py": conanfile}) + client.run("create .") + save(client.cache.new_config_path, "tools.env.virtualenv:fish=True\n") + client.save({"conanfile.py": GenConanfile("app", "0.1").with_requires("pkg/0.1")}) + client.run("install . -s:a os=Linux") + + # Only generated fish scripts when virtualenv:fish=True + for ext in ["*.sh", "*.bat"]: + not_expected_files = glob.glob(os.path.join(client.current_folder, ext)) + assert not not_expected_files + + # It does not generate conanrun because there is no variables to be exported + expected_files = sorted(glob.glob(os.path.join(client.current_folder, "*.fish"))) + assert [os.path.join(client.current_folder, "conanbuild.fish"), + os.path.join(client.current_folder, "conanbuildenv.fish")] == expected_files + + buildenv = load(os.path.join(client.current_folder, "conanbuildenv.fish")) + assert f'set -gx MYPATH1 "{my_path}"' in buildenv + assert f'set -gx MYVAR1 "{my_var1}"' in buildenv + assert f'set -gx MYVAR2 "{my_var2}"' in buildenv + assert f'set -pgx PATH "{fake_path}"' in buildenv + + wrap_with_quotes = lambda s: f"'{s}'" if ' ' in s else s + client.run_command('fish -c ". conanbuild.fish; and set; and deactivate_conanbuildenv; and set"') + # Define variables only once and remove after running deactivate_conanbuildenv + assert str(client.out).count(f"\nMYPATH1 {wrap_with_quotes(my_path)}\n") == 1 + assert str(client.out).count(f"\nMYVAR1 {wrap_with_quotes(my_var1)}\n") == 1 + assert str(client.out).count(f"\nMYVAR2 {wrap_with_quotes(my_var2)}\n") == 1 + assert str(client.out).count(f"\nPATH '{fake_path}'") == 1 + # Temporary variables to store names should be removed as well + assert str(client.out).count(f"_PATH {wrap_with_quotes(fake_path)}\n") == 1 + assert str(client.out).count("_del 'MYPATH1' 'MYVAR1' 'MYVAR2'\n") == 1 + + # Running conanbuild.fish twice should append variables, but not override them + client.run_command('fish -c ". conanbuild.fish; . conanbuild.fish; and set"') + assert str(client.out).count(f"\nMYPATH1 '{my_path}' '{my_path}'") == 1 + assert str(client.out).count(f"\nMYVAR1 '{my_var1}' '{my_var1}'") == 1 + assert str(client.out).count(f"\nMYVAR2 '{my_var2}' '{my_var2}'") == 1 + assert str(client.out).count(f"\nPATH '{fake_path}' '{fake_path}'") == 1 + + +@pytest.mark.tool("fish") +@pytest.mark.parametrize("fish_value", [True, False]) +@pytest.mark.parametrize("path_with_spaces", [True, False]) +@pytest.mark.parametrize("value", ["Dulcinea del Toboso", "Dulcinea-Del-Toboso"]) +def test_buildenv_transitive_tool_requires(fish_value, path_with_spaces, value): + """Generate a tool require package, which provides and binary and a custom environment variable. + Using fish, the binary should be available in the path, and the environment variable too. + """ + client = TestClient(path_with_spaces=path_with_spaces) + save(client.cache.new_config_path, f"tools.env.virtualenv:fish={fish_value}\n") + + # Generate the tool package with pkg-echo-tool binary that prints the value of LADY env var + cmd_line = "echo ${LADY}" if platform.system() != "Windows" else "echo %LADY%" + conanfile = str(GenConanfile("tool", "0.1.0") + .with_package_file("bin/pkg-echo-tool", cmd_line)) + package_info = f""" + os.chmod(os.path.join(self.package_folder, "bin", "pkg-echo-tool"), 0o777) + + def package_info(self): + self.buildenv_info.define("LADY", "{value}") + """ + conanfile += package_info + client.save({"tool/conanfile.py": conanfile}) + client.run("create tool") + + assert "tool/0.1.0: package(): Packaged 1 file: pkg-echo-tool" in client.out + + # Generate the app package that uses the tool package. It should be able to run the binary and + # access the environment variable as well. + conanfile = str(GenConanfile("app", "0.1.0") + .with_tool_requires("tool/0.1.0") + .with_generator("VirtualBuildEnv")) + build = """ + def build(self): + self.run("pkg-echo-tool", env="conanbuild") + """ + conanfile += build + + client.save({"app/conanfile.py": conanfile}) + client.run("create app") + + assert value in client.out + + +@pytest.mark.tool("fish") +@pytest.mark.parametrize("fake_path", ["/path/to/fake/folder", "/path/with space/to/folder"]) +@pytest.mark.parametrize("fake_define", ["FOOBAR", "FOO BAR"]) +def test_runenv_buildenv_define_new_vars(fake_path, fake_define): + """Test when defining new path and new variable using both buildenvinfo and runenvinfo. + + Variables should be available as environment variables in the buildenv and runenv scripts. + And, should be deleted when deactivate_conanbuildenv and deactivate_conanrunenv are called. + """ + cache_folder = os.path.join(temp_folder(), "[sub] folder") + client = TestClient(cache_folder) + conanfile = str(GenConanfile("pkg", "0.1")) + conanfile += f""" + + def package_info(self): + self.buildenv_info.define("FAKE_DEFINE", "{fake_define}") + self.runenv_info.define_path("FAKE_PATH", "{fake_path}") + """ + client.save({"conanfile.py": conanfile}) + client.run("create .") + save(client.cache.new_config_path, "tools.env.virtualenv:fish=True\n") + client.save({"conanfile.py": GenConanfile("app", "0.1").with_requires("pkg/0.1")}) + client.run("install . -s:a os=Linux") + + # Only generated fish scripts when virtualenv:fish=True + for ext in ["*.sh", "*.bat"]: + not_expected_files = glob.glob(os.path.join(client.current_folder, ext)) + assert not not_expected_files + + expected_files = sorted(glob.glob(os.path.join(client.current_folder, "*.fish"))) + assert [os.path.join(client.current_folder, "conanbuild.fish"), + os.path.join(client.current_folder, "conanbuildenv.fish"), + os.path.join(client.current_folder, "conanrun.fish"), + os.path.join(client.current_folder, "conanrunenv.fish")] == expected_files + + # Do not mix buildenv and runenv variables + script_path = os.path.join(client.current_folder, "conanbuildenv.fish") + script_content = load(script_path) + assert f'set -gx FAKE_DEFINE "{fake_define}"' in script_content + assert 'FAKE_PATH' not in script_content + + script_path = os.path.join(client.current_folder, "conanrunenv.fish") + script_content = load(script_path) + assert f'set -pgx FAKE_PATH "{fake_path}"' in script_content + assert 'FAKE_DEFINE' not in script_content + + # Check if generated wrappers are sourcing the env scripts + for group in ['build', 'run']: + script_path = os.path.join(client.current_folder, f"conan{group}.fish") + script_content = load(script_path) + assert f'. "{os.path.join(client.current_folder, f"conan{group}env.fish")}"' in script_content + + # Check if the variables are available in the environment + client.run_command(f'fish -c ". conanbuild.fish; . conanrun.fish; set; deactivate_conanbuildenv; deactivate_conanrunenv; set"') + wrap_with_quotes = lambda s: f"'{s}'" if ' ' in s else s + assert f"FAKE_PATH {wrap_with_quotes(fake_path)}" in client.out + assert f"FAKE_DEFINE {wrap_with_quotes(fake_define)}" in client.out + # It finds 2 because there is a variable with all values names to be deleted + assert client.out.count('FAKE_PATH') == 2 + assert client.out.count('FAKE_DEFINE') == 2 + + deactivated_content = client.out[client.out.index("Restoring environment"):] + assert deactivated_content.count('FAKE_PATH') == 0 + assert deactivated_content.count('FAKE_DEFINE') == 0 + + +def test_no_generate_fish(): + """Test when not defining variables and using Fish as virtualenv + + Conan should not generate any .fish file as there is no variables to be exported + """ + cache_folder = os.path.join(temp_folder(), "[sub] folder") + client = TestClient(cache_folder) + conanfile = str(GenConanfile("pkg", "0.1")) + client.save({"conanfile.py": conanfile}) + client.run("create .") + save(client.cache.new_config_path, "tools.env.virtualenv:fish=True\n") + client.save({"conanfile.py": GenConanfile("app", "0.1").with_requires("pkg/0.1")}) + client.run("install . -s:a os=Linux") + + # Do not generate any virtualenv + for ext in ["*.sh", "*.bat", "*.fish"]: + not_expected_files = glob.glob(os.path.join(client.current_folder, ext)) + assert not not_expected_files