Repository: conan-io/conan

Issue:
virtualenv generator with `set -u` leads to unbound variable failure
Conan 1.9.0, ubuntu 16.04

If a package sets env_info to a list (so that [tools.environment_append](https://docs.conan.io/en/latest/reference/tools.html?highlight=environment_append#tools-environment-append) will prepend it with a delimter, the virtualenv generator produces an assignment like:

`PYTHONPATH=".../release/package/5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9/python":$PYTHONPATH`

If there is no current value in that variable, and `set -u` is in effect, this will cause the shell to error out with `bash: PYTHONPATH: unbound variable`

To fix this, the virtualenv generators should explicitly use a conditional expansion like ${PYTHONPATH:-}, that can substitute to nothing if the variable is unset (even with set -u, since this was explicitly requested). But since I think it would also be nice to not get a junk trailing delimiter when there's nothing to append, I suggest:

`val1:val2${NAME+:$NAME}`

Despite being a little fancy, `${VAR:-}` and `${VAR+}` are portable posix sh substitutions, not bashisms (see [section 26.2 Parameter Expansion](http://pubs.opengroup.org/onlinepubs/009695399/utilities/xcu_chap02.html#tag_02_06_02))

- [x] I've read the [CONTRIBUTING guide](https://raw.githubusercontent.com/conan-io/conan/develop/.github/CONTRIBUTING.md).
- [x] I've specified the Conan version, operating system version and any tool that can be relevant.
- [x] I've explained the steps to reproduce the error or the motivation/use case of the question/suggestion.




Discussion:
Probably it is better to not set the variable if we detect it is empty.
I'm talking about when the ConanFile.env has stuff to prepend to a list-variable, but there's no current entries. You do need to set the variable, to add the additional entries that the profile/recipes provided. But right now sourcing the virtualenv scripts errors out (if you have sh set to make unset variable substitutions as an error) when there's nothing to prepend the new values in front of. Or, if you don't have that set as an error, it adds a trailing delimiter on the list of new values anyway, which could plausibly be read as the list having an empty string as the last element. 

Using `${NAME+:$NAME}` fixes both problems; it explicitly handles the case where NAME is previously unset, and nothing is appended (not even the delimiter) if name is previously unset or empty.

Resolving patch:
diff --git a/conans/client/generators/virtualenv.py b/conans/client/generators/virtualenv.py
--- a/conans/client/generators/virtualenv.py
+++ b/conans/client/generators/virtualenv.py
@@ -17,7 +17,7 @@ def __init__(self, conanfile):
     def filename(self):
         return
 
-    def _variable_placeholder(self, flavor, name):
+    def _variable_placeholder(self, flavor, name, append_with_spaces):
         """
         :param flavor: flavor of the execution environment
         :param name: variable name
@@ -28,7 +28,8 @@ def _variable_placeholder(self, flavor, name):
             return "%%%s%%" % name
         if flavor == "ps1":
             return "$env:%s" % name
-        return "$%s" % name  # flavor == sh
+        # flavor == sh
+        return "${%s+ $%s}" % (name, name) if append_with_spaces else "${%s+:$%s}" % (name,  name)
 
     def format_values(self, flavor, variables):
         """
@@ -49,8 +50,9 @@ def format_values(self, flavor, variables):
         for name, value in variables:
             # activate values
             if isinstance(value, list):
-                placeholder = self._variable_placeholder(flavor, name)
-                if name in self.append_with_spaces:
+                append_with_spaces = name in self.append_with_spaces
+                placeholder = self._variable_placeholder(flavor, name, append_with_spaces)
+                if append_with_spaces:
                     # Variables joined with spaces look like: CPPFLAGS="one two three"
                     value = " ".join(value+[placeholder])
                     value = "\"%s\"" % value if quote_elements else value
@@ -59,7 +61,10 @@ def format_values(self, flavor, variables):
                     # PATH="one path":"two paths"
                     # Unquoted variables joined with pathset may look like: PATH=one path;two paths
                     value = ["\"%s\"" % v for v in value] if quote_elements else value
-                    value = path_sep.join(value+[placeholder])
+                    if flavor == "sh":
+                        value = path_sep.join(value) + placeholder
+                    else:
+                        value = path_sep.join(value + [placeholder])
             else:
                 # single value
                 value = "\"%s\"" % value if quote_elements else value


Test patch:
diff --git a/conans/test/functional/configuration/profile_test.py b/conans/test/functional/configuration/profile_test.py
--- a/conans/test/functional/configuration/profile_test.py
+++ b/conans/test/functional/configuration/profile_test.py
@@ -54,12 +54,11 @@ def profile_conanfile_txt_test(self):
                        package_env={"Hello0": [("OTHER_VAR", "2")]})
         self.client.run("install . -pr envs -g virtualenv")
         content = load(os.path.join(self.client.current_folder, "activate.sh"))
-        self.assertIn(":".join(["PREPEND_VAR=\"new_path\"", "\"other_path\"", "$PREPEND_VAR"]),
-                      content)
+        self.assertIn(":".join(["PREPEND_VAR=\"new_path\"", "\"other_path\""]) +
+                      "${PREPEND_VAR+:$PREPEND_VAR}", content)
         if platform.system() == "Windows":
             content = load(os.path.join(self.client.current_folder, "activate.bat"))
-            self.assertIn(";".join(["PREPEND_VAR=new_path", "other_path", "%PREPEND_VAR%"]),
-                          content)
+            self.assertIn(";".join(["PREPEND_VAR=new_path", "other_path", "%PREPEND_VAR%"]), content)
 
     def test_profile_relative_cwd(self):
         self.client.save({"conanfile.txt": "", "sub/sub/profile": ""})
diff --git a/conans/test/functional/environment/__init__.py b/conans/test/functional/environment/__init__.py
new file mode 100644
diff --git a/conans/test/integration/conan_env_test.py b/conans/test/functional/environment/apply_environment_test.py
similarity index 99%
rename from conans/test/integration/conan_env_test.py
rename to conans/test/functional/environment/apply_environment_test.py
--- a/conans/test/integration/conan_env_test.py
+++ b/conans/test/functional/environment/apply_environment_test.py
@@ -438,8 +438,9 @@ def package_info(self):
         if platform.system() == "Windows":
             self.assertIn('var2=value3;value2;%var2%', activate_contents)
         else:
-            self.assertIn('var2="value3":"value2":$var2', activate_contents)
-            self.assertIn('CPPFLAGS="OtherFlag=2 MYCPPFLAG=1 $CPPFLAGS"', activate_contents)
+            self.assertIn('var2="value3":"value2"${var2+:$var2}', activate_contents)
+            self.assertIn('CPPFLAGS="OtherFlag=2 MYCPPFLAG=1 ${CPPFLAGS+ $CPPFLAGS}"',
+                          activate_contents)
         self.assertIn("Another value", activate_contents)
         if platform.system() == "Windows":
             self.assertIn("PATH=/dir", activate_contents)
@@ -673,7 +674,7 @@ def package_info(self):
 PATH=["path_from_B"]""", info)
         if platform.system() != "Windows":
             activate = load(os.path.join(client.current_folder, "activate.sh"))
-            self.assertIn('PATH="path_from_A":"path_from_B":$PATH', activate)
+            self.assertIn('PATH="path_from_A":"path_from_B"${PATH+:$PATH}', activate)
         else:
             activate = load(os.path.join(client.current_folder, "activate.bat"))
             self.assertIn('PATH=path_from_A;path_from_B;%PATH%', activate)
diff --git a/conans/test/functional/generators/virtualenv_test.py b/conans/test/functional/generators/virtualenv_test.py
--- a/conans/test/functional/generators/virtualenv_test.py
+++ b/conans/test/functional/generators/virtualenv_test.py
@@ -1,5 +1,6 @@
 import copy
 import os
+import platform
 import unittest
 
 from conans.client import tools
@@ -106,22 +107,26 @@ def package_info(self):
         self.assertIn('export OLD_PS1', activate)
         self.assertIn('PS1="(conanenv) $PS1"', activate)
         self.assertIn('export PS1', activate)
-        self.assertIn('BASE_LIST="dummyValue1":"dummyValue2":"baseValue1":"baseValue2":$BASE_LIST', activate)
+        self.assertIn('BASE_LIST="dummyValue1":"dummyValue2":"baseValue1":"baseValue2"${'
+                      'BASE_LIST+:$BASE_LIST}', activate)
         self.assertIn('export BASE_LIST', activate)
         self.assertIn('BASE_VAR="baseValue"', activate)
         self.assertIn('export BASE_VAR', activate)
-        self.assertIn('CPPFLAGS="-flag1 -flag2 -baseFlag1 -baseFlag2 $CPPFLAGS"', activate)
+        self.assertIn('CPPFLAGS="-flag1 -flag2 -baseFlag1 -baseFlag2 ${CPPFLAGS+ $CPPFLAGS}"',
+                      activate)
         self.assertIn('export CPPFLAGS', activate)
         self.assertIn('SPECIAL_VAR="dummyValue"', activate)
         self.assertIn('export SPECIAL_VAR', activate)
         self.assertIn('BCKW_SLASH="dummy\\value"', activate)
         self.assertIn('export BCKW_SLASH', activate)
         if os_info.is_windows:
-            self.assertIn('LD_LIBRARY_PATH="dummydir\\lib":"basedir\\lib":$LD_LIBRARY_PATH', activate)
-            self.assertIn('PATH="dummydir\\bin":"basedir\\bin":"samebin":$PATH', activate)
+            self.assertIn('LD_LIBRARY_PATH="dummydir\\lib":"basedir\\lib"${'
+                          'LD_LIBRARY_PATH+:$LD_LIBRARY_PATH}', activate)
+            self.assertIn('PATH="dummydir\\bin":"basedir\\bin":"samebin"${PATH+:$PATH}', activate)
         else:
-            self.assertIn('LD_LIBRARY_PATH="dummydir/lib":"basedir/lib":$LD_LIBRARY_PATH', activate)
-            self.assertIn('PATH="dummydir/bin":"basedir/bin":"samebin":$PATH', activate)
+            self.assertIn('LD_LIBRARY_PATH="dummydir/lib":"basedir/lib"${'
+                          'LD_LIBRARY_PATH+:$LD_LIBRARY_PATH}', activate)
+            self.assertIn('PATH="dummydir/bin":"basedir/bin":"samebin"${PATH+:$PATH}', activate)
         self.assertIn('export LD_LIBRARY_PATH', activate)
         self.assertIn('export PATH', activate)
         deactivate = load(os.path.join(client.current_folder, "deactivate.sh"))
@@ -169,3 +174,23 @@ def environment_test(self):
                "BCKW_SLASH": "old_BCKW_SLASH"}
         with tools.environment_append(env):
             self.basic_test(posix_empty_vars=False)
+
+    @unittest.skipIf(platform.system() == "Windows", "Needs bash")
+    def conditional_parameter_expansion_test(self):
+        # https://github.com/conan-io/conan/issues/3911
+        client = TestClient()
+        client.save({"conanfile.txt": ""})
+        client.run("profile new default --detect")
+        client.run("profile update env.PREPEND_VAR=[1,2,three] default")
+        client.run("install . -g virtualenv")
+        activate = load(os.path.join(client.current_folder, "activate.sh"))
+        self.assertIn("PREPEND_VAR=\"1\":\"2\":\"three\"${PREPEND_VAR+:$PREPEND_VAR}", activate)
+        client.runner("%s -c 'source \"%s/activate.sh\" && env'" % (OSInfo.bash_path(),
+                                                                    client.current_folder))
+        # Check no trailing path separator ":"
+        self.assertNotIn("PREPEND_VAR=1:2:three:", client.out)
+        self.assertIn("PREPEND_VAR=1:2:three", client.out)  # Check correct value
+        # Check old value is preserved
+        client.runner("%s -c 'export PREPEND_VAR=kk && source \"%s/activate.sh\" && env'" %
+                      (OSInfo.bash_path(), client.current_folder))
+        self.assertIn("PREPEND_VAR=1:2:three:kk", client.out)
diff --git a/conans/test/unittests/client/generators/virtualenv_test.py b/conans/test/unittests/client/generators/virtualenv_test.py
--- a/conans/test/unittests/client/generators/virtualenv_test.py
+++ b/conans/test/unittests/client/generators/virtualenv_test.py
@@ -17,6 +17,6 @@ def prepend_values_test(self):
         conanfile.initialize(Settings({}), EnvValues.loads("PATH=[1,2,three]"))
         gen = VirtualEnvGenerator(conanfile)
         content = gen.content
-        self.assertIn("PATH=\"1\":\"2\":\"three\":$PATH", content["activate.sh"])
+        self.assertIn("PATH=\"1\":\"2\":\"three\"${PATH+:$PATH}", content["activate.sh"])
         if platform.system() == "Windows":
             self.assertIn("PATH=1;2;three;%PATH%", content["activate.bat"])