Repository: spack/spack

Issue:
Cannot write module files with path modifications containing the `{}` characters
### Steps to reproduce

I’m struggling to set an environment variable in our `setup_run_environment` whose value must contain `{}`, e.g. this is the value my env variable should have when I load the package: `async under_vol=0;under_info={}`

```
def setup_run_environment(self, env):
    env.set('HDF5_PLUGIN_PATH', self.spec.prefix)
    vol_connector = 'async under_vol=0;under_info={}'
    env.set('HDF5_VOL_CONNECTOR', vol_connector)
    env.set('MPICH_MAX_THREAD_SAFETY', 'multiple')
```

Discussing with @tgamblin, we tried it with `zlib`, which works without an issue. But in my package I get:

```console
==> hdf5-vol-async: Executing phase: 'cmake'
==> hdf5-vol-async: Executing phase: 'build'
==> hdf5-vol-async: Executing phase: 'install'
==> Error: SpecFormatStringError: Format string attributes must be non-empty

/Users/jlbez/Documents/repositories/spack/lib/spack/spack/build_environment.py:1065, in _setup_pkg_and_run:
       1062        tb_string = traceback.format_exc()
       1063
       1064        # build up some context from the offending package so we can
  >>   1065        # show that, too.
       1066        package_context = get_package_context(tb)
       1067
       1068        logfile = None
```

@tgamblin mentioned that since spack allows us to use, e.g., `{name}`  and other spec format strings in variable names for module file templates, that is failing in module generation. As suggested, I'm also pinging @alalazo in this issue.

### Error message

```console
==> No patches needed for hdf5-vol-async
==> hdf5-vol-async: Executing phase: 'cmake'
==> hdf5-vol-async: Executing phase: 'build'
==> hdf5-vol-async: Executing phase: 'install'
==> [2022-03-17-18:04:50.690442] Error: SpecFormatStringError: Format string attributes must be non-empty

/Users/jlbez/Documents/repositories/spack/lib/spack/spack/build_environment.py:1065, in _setup_pkg_and_run:
       1062        tb_string = traceback.format_exc()
       1063
       1064        # build up some context from the offending package so we can
  >>   1065        # show that, too.
       1066        package_context = get_package_context(tb)
       1067
       1068        logfile = None

See build log for details:
  /var/folders/rk/35_wlhq12b35gv8ch8z58cqc0000gn/T/jlbez/spack-stage/spack-stage-hdf5-vol-async-1.0-jlbdspjkoslsyqbosrbolvqbwkde43fu/spack-build-out.txt

Traceback (most recent call last):
  File "/Users/jlbez/Documents/repositories/spack/lib/spack/spack/build_environment.py", line 1049, in _setup_pkg_and_run
    return_value = function(pkg, kwargs)
  File "/Users/jlbez/Documents/repositories/spack/lib/spack/spack/installer.py", line 2017, in build_process
    return installer.run()
  File "/Users/jlbez/Documents/repositories/spack/lib/spack/spack/installer.py", line 1879, in run
    spack.hooks.post_install(self.pkg.spec)
  File "/Users/jlbez/Documents/repositories/spack/lib/spack/spack/hooks/__init__.py", line 76, in __call__
    hook(*args, **kwargs)
  File "/Users/jlbez/Documents/repositories/spack/lib/spack/spack/hooks/module_file_generation.py", line 47, in post_install
    _for_each_enabled(spec, 'write')
  File "/Users/jlbez/Documents/repositories/spack/lib/spack/spack/hooks/module_file_generation.py", line 32, in _for_each_enabled
    getattr(generator, method_name)()
  File "/Users/jlbez/Documents/repositories/spack/lib/spack/spack/modules/common.py", line 886, in write
    context = self.context.to_dict()
  File "/Users/jlbez/Documents/repositories/spack/lib/spack/spack/tengine.py", line 65, in to_dict
    d = [(name, getattr(self, name)) for name in self.context_properties]
  File "/Users/jlbez/Documents/repositories/spack/lib/spack/spack/tengine.py", line 65, in <listcomp>
    d = [(name, getattr(self, name)) for name in self.context_properties]
  File "/Users/jlbez/Documents/repositories/spack/lib/spack/spack/modules/common.py", line 766, in environment_modifications
    x.value = spec.format(x.value)
  File "/Users/jlbez/Documents/repositories/spack/lib/spack/spack/spec.py", line 4197, in format
    write_attribute(self, attribute, color)
  File "/Users/jlbez/Documents/repositories/spack/lib/spack/spack/spec.py", line 4083, in write_attribute
    raise SpecFormatStringError(
spack.spec.SpecFormatStringError: Format string attributes must be non-empty
==> [2022-03-17-18:04:50.693633] Flagging hdf5-vol-async-1.0-jlbdspjkoslsyqbosrbolvqbwkde43fu as failed: SpecFormatStringError: Format string attributes must be non-empty
==> [2022-03-17-18:04:50.709935] ChildError: SpecFormatStringError: Format string attributes must be non-empty
```

### Information on your system

I tried it on MacOS:

```
* **Spack:** 0.17.1-1610-0ce8b9d398
* **Python:** 3.9.10
* **Platform:** darwin-monterey-cannonlake
* **Concretizer:** clingo
```

I also tried it on Ubuntu:

```
* **Spack:** 0.17.1-1610-0ce8b9d398
* **Python:** 3.8.10
* **Platform:** linux-ubuntu20.04-sandybridge
* **Concretizer:** clingo
```

### General information

- [X] I have run `spack debug report` and reported the version of Spack/Python/Platform
- [X] I have searched the issues of this repo and believe this is not a duplicate
- [X] I have run the failing commands in debug mode and reported the output


Discussion:
@alalazo we were discussing with the HDF5 group about the possibility to replace the `{}` syntax to avoid any conflicts with spack as some packages now face this issue. If we were to replace that with something like `[]` would that cause any other internal conflicts with spack or do you have any other suggestion in that case, or is this a quick fix in spack?
@jeanbez Let me try to reproduce
Ok, this error happens when writing module files. We might be able to quote `{` in that context. 
@alalazo so the quote would be done internally in spack? I've tried some workaround to make it escape as it is today but they are ignored or the quotes are forwarded and set as env variables, which breaks stuff 

Resolving patch:
diff --git a/lib/spack/spack/modules/common.py b/lib/spack/spack/modules/common.py
--- a/lib/spack/spack/modules/common.py
+++ b/lib/spack/spack/modules/common.py
@@ -671,6 +671,12 @@ def configure_options(self):
         # the configure option section
         return None
 
+    def modification_needs_formatting(self, modification):
+        """Returns True if environment modification entry needs to be formatted."""
+        return (
+            not isinstance(modification, (spack.util.environment.SetEnv)) or not modification.raw
+        )
+
     @tengine.context_property
     @memoized
     def environment_modifications(self):
@@ -734,11 +740,12 @@ def environment_modifications(self):
             _check_tokens_are_valid(x.name, message=msg)
             # Transform them
             x.name = spec.format(x.name, transform=transform)
-            try:
-                # Not every command has a value
-                x.value = spec.format(x.value)
-            except AttributeError:
-                pass
+            if self.modification_needs_formatting(x):
+                try:
+                    # Not every command has a value
+                    x.value = spec.format(x.value)
+                except AttributeError:
+                    pass
             x.name = str(x.name).replace("-", "_")
 
         return [(type(x).__name__, x) for x in env if x.name not in exclude]
diff --git a/lib/spack/spack/util/environment.py b/lib/spack/spack/util/environment.py
--- a/lib/spack/spack/util/environment.py
+++ b/lib/spack/spack/util/environment.py
@@ -351,13 +351,20 @@ def execute(self, env: MutableMapping[str, str]):
 
 
 class SetEnv(NameValueModifier):
-    __slots__ = ("force",)
+    __slots__ = ("force", "raw")
 
     def __init__(
-        self, name: str, value: str, *, trace: Optional[Trace] = None, force: bool = False
+        self,
+        name: str,
+        value: str,
+        *,
+        trace: Optional[Trace] = None,
+        force: bool = False,
+        raw: bool = False,
     ):
         super().__init__(name, value, trace=trace)
         self.force = force
+        self.raw = raw
 
     def execute(self, env: MutableMapping[str, str]):
         tty.debug(f"SetEnv: {self.name}={str(self.value)}", level=3)
@@ -501,15 +508,16 @@ def _trace(self) -> Optional[Trace]:
         return Trace(filename=filename, lineno=lineno, context=current_context)
 
     @system_env_normalize
-    def set(self, name: str, value: str, *, force: bool = False):
+    def set(self, name: str, value: str, *, force: bool = False, raw: bool = False):
         """Stores a request to set an environment variable.
 
         Args:
             name: name of the environment variable
             value: value of the environment variable
             force: if True, audit will not consider this modification a warning
+            raw: if True, format of value string is skipped
         """
-        item = SetEnv(name, value, trace=self._trace(), force=force)
+        item = SetEnv(name, value, trace=self._trace(), force=force, raw=raw)
         self.env_modifications.append(item)
 
     @system_env_normalize
diff --git a/var/spack/repos/builtin.mock/packages/module-setenv-raw/package.py b/var/spack/repos/builtin.mock/packages/module-setenv-raw/package.py
new file mode 100644
--- /dev/null
+++ b/var/spack/repos/builtin.mock/packages/module-setenv-raw/package.py
@@ -0,0 +1,16 @@
+# Copyright 2013-2023 Lawrence Livermore National Security, LLC and other
+# Spack Project Developers. See the top-level COPYRIGHT file for details.
+#
+# SPDX-License-Identifier: (Apache-2.0 OR MIT)
+
+from spack.package import *
+
+
+class ModuleSetenvRaw(Package):
+    homepage = "http://www.llnl.gov"
+    url = "http://www.llnl.gov/module-setenv-raw-1.0.tar.gz"
+
+    version("1.0", "0123456789abcdef0123456789abcdef")
+
+    def setup_run_environment(self, env):
+        env.set("FOO", "{{name}}, {name}, {{}}, {}", raw=True)


Test patch:
diff --git a/lib/spack/spack/test/modules/lmod.py b/lib/spack/spack/test/modules/lmod.py
--- a/lib/spack/spack/test/modules/lmod.py
+++ b/lib/spack/spack/test/modules/lmod.py
@@ -198,6 +198,15 @@ def test_manpath_setup(self, modulefile_content, module_configuration):
         assert len([x for x in content if 'setenv("MANPATH", "/path/to/man")' in x]) == 1
         assert len([x for x in content if 'append_path("MANPATH", "", ":")' in x]) == 0
 
+    @pytest.mark.regression("29578")
+    def test_setenv_raw_value(self, modulefile_content, module_configuration):
+        """Tests that we can set environment variable value without formatting it."""
+
+        module_configuration("autoload_direct")
+        content = modulefile_content("module-setenv-raw")
+
+        assert len([x for x in content if 'setenv("FOO", "{{name}}, {name}, {{}}, {}")' in x]) == 1
+
     def test_help_message(self, modulefile_content, module_configuration):
         """Tests the generation of module help message."""
 
diff --git a/lib/spack/spack/test/modules/tcl.py b/lib/spack/spack/test/modules/tcl.py
--- a/lib/spack/spack/test/modules/tcl.py
+++ b/lib/spack/spack/test/modules/tcl.py
@@ -182,6 +182,15 @@ def test_manpath_setup(self, modulefile_content, module_configuration):
         assert len([x for x in content if 'setenv MANPATH "/path/to/man"' in x]) == 1
         assert len([x for x in content if 'append-path --delim ":" MANPATH ""' in x]) == 0
 
+    @pytest.mark.regression("29578")
+    def test_setenv_raw_value(self, modulefile_content, module_configuration):
+        """Tests that we can set environment variable value without formatting it."""
+
+        module_configuration("autoload_direct")
+        content = modulefile_content("module-setenv-raw")
+
+        assert len([x for x in content if 'setenv FOO "{{name}}, {name}, {{}}, {}"' in x]) == 1
+
     def test_help_message(self, modulefile_content, module_configuration):
         """Tests the generation of module help message."""