Repository: spack/spack

Issue:
Variant `when=`: Change of Default for Single/Multi-Variant Transition
### Steps to reproduce

In https://github.com/spack/spack/pull/37695, I introduced that a variant becomes a multi-variant for a certain version and later.

This [does not work](https://github.com/spack/spack/pull/37695#issuecomment-1558434361): Spack tries to always apply the new multi-variant defaults, even for older versions excluded by then variant `when` condition.

```py
    variant(
        "dimensions",
        default="3",
        values=("1", "2", "3"),
        multi=False,
        description="Dimensionality",
        when="@:23.05",
    )
    variant(
        "dimensions",
        default="1,2,3",
        values=("1", "2", "3"),
        multi=True,
        description="Dimensionality",
        when="@23.06:",
    )
```

### Error message

```bash
spack spec amrex  # >= 23.06
# all great!
```

But:
```bash
spack spec amrex@23.05
```
```
...
amrex@23.05%gcc@12.1.0~amrdata~cuda~eb~fortran~hdf5~hypre~ipo+linear_solvers+mpi~openmp~particles~petsc~pic~plotfile_tools~rocm~shared~sundials~sycl~tiny_profile build_system=cmake build_type=Release dimensions=1,2,3 generator=make precision=double arch=linux-ubuntu22.04-skylake
...
```
(should be `dimensions=3`)

```bash
spack install amrex@23.05
```
```
...
==> Installing amrex-23.05-5crixpvv3zbjvxrdyqkxc7lsqngbcmuv
==> No binary for amrex-23.05-5crixpvv3zbjvxrdyqkxc7lsqngbcmuv found: installing from source
==> Using cached archive: /home/axel/src/spack/var/spack/cache/_source-cache/archive/a4/a4bf5ad5322e706b9fae46ff52043e2cca5ddba81479647816251e9ab21c0027.tar.gz
==> No patches needed for amrex
==> amrex: Executing phase: 'cmake'
==> Error: ProcessError: Command exited with status 1:
    '/home/axel/src/spack/opt/spack/linux-ubuntu22.04-skylake/gcc-12.1.0/cmake-3.25.2-54fooma2lakox5es5ucus5kzpbn7xosy/bin/cmake' ... '-DAMReX_SPACEDIM:STRING=1;2;3' ...
...
(expected CMake configure-time error follows)
```
(should be `-DAMReX_SPACEDIM=3`)

### Information on your system

* **Spack:** 0.21.0.dev0 (2cdc902ead5894d2fc0ee93de7564c0c883e7d6b)
* **Python:** 3.10.6
* **Platform:** linux-ubuntu22.04-skylake
* **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:
Not sure I'd call this a bug, since it's documented, see [this section of the docs](https://spack.readthedocs.io/en/latest/packaging_guide.html#overriding-variants) :slightly_smiling_face: 

As a workaround you might use two slightly different names for the variants. In any case, I noted the use case! Thanks for the report.
Comment by @tgamblin on Slack:
> FYI: in re-structuring the directive metadata on classes, I’ve got variants indexed by condition (instead of name) now.  Which should allow us to have conditional defaults (which you can currently express but which do not work).  I think I can fix this

I will try `develop` on AMReX' package.
`development` as of ebd41134fe4001760dde1269baa53b922e00ee15 with #37695:

Good (new hotness multi-variant):
```
spack spec --fresh amrex
... dimension=1,2,3
```

Still kaputt (old versions with single variant) :
```
spack spec --fresh amrex@23.05
... dimension=1,2,3
should be:
... dimension=3
```
@ax3l Changed this to a feature proposal, added it to v0.21 (planned for release around SC23)

Resolving patch:
diff --git a/lib/spack/spack/cmd/info.py b/lib/spack/spack/cmd/info.py
--- a/lib/spack/spack/cmd/info.py
+++ b/lib/spack/spack/cmd/info.py
@@ -3,6 +3,7 @@
 #
 # SPDX-License-Identifier: (Apache-2.0 OR MIT)
 
+import sys
 import textwrap
 from itertools import zip_longest
 
@@ -16,6 +17,7 @@
 import spack.install_test
 import spack.repo
 import spack.spec
+import spack.version
 from spack.package_base import preferred_version
 
 description = "get detailed information on a particular package"
@@ -53,6 +55,7 @@ def setup_parser(subparser):
         ("--tags", print_tags.__doc__),
         ("--tests", print_tests.__doc__),
         ("--virtuals", print_virtuals.__doc__),
+        ("--variants-by-name", "list variants in strict name order; don't group by condition"),
     ]
     for opt, help_comment in options:
         subparser.add_argument(opt, action="store_true", help=help_comment)
@@ -77,35 +80,10 @@ def license(s):
 
 
 class VariantFormatter:
-    def __init__(self, variants):
-        self.variants = variants
+    def __init__(self, pkg):
+        self.variants = pkg.variants
         self.headers = ("Name [Default]", "When", "Allowed values", "Description")
 
-        # Formats
-        fmt_name = "{0} [{1}]"
-
-        # Initialize column widths with the length of the
-        # corresponding headers, as they cannot be shorter
-        # than that
-        self.column_widths = [len(x) for x in self.headers]
-
-        # Expand columns based on max line lengths
-        for k, e in variants.items():
-            v, w = e
-            candidate_max_widths = (
-                len(fmt_name.format(k, self.default(v))),  # Name [Default]
-                len(str(w)),
-                len(v.allowed_values),  # Allowed values
-                len(v.description),  # Description
-            )
-
-            self.column_widths = (
-                max(self.column_widths[0], candidate_max_widths[0]),
-                max(self.column_widths[1], candidate_max_widths[1]),
-                max(self.column_widths[2], candidate_max_widths[2]),
-                max(self.column_widths[3], candidate_max_widths[3]),
-            )
-
         # Don't let name or possible values be less than max widths
         _, cols = tty.terminal_size()
         max_name = min(self.column_widths[0], 30)
@@ -137,6 +115,8 @@ def default(self, v):
     def lines(self):
         if not self.variants:
             yield "    None"
+            return
+
         else:
             yield "    " + self.fmt % self.headers
             underline = tuple([w * "=" for w in self.column_widths])
@@ -271,15 +251,165 @@ def print_tests(pkg):
         color.cprint("    None")
 
 
-def print_variants(pkg):
+def _fmt_value(v):
+    if v is None or isinstance(v, bool):
+        return str(v).lower()
+    else:
+        return str(v)
+
+
+def _fmt_name_and_default(variant):
+    """Print colorized name [default] for a variant."""
+    return color.colorize(f"@c{{{variant.name}}} @C{{[{_fmt_value(variant.default)}]}}")
+
+
+def _fmt_when(when, indent):
+    return color.colorize(f"{indent * ' '}@B{{when}} {color.cescape(when)}")
+
+
+def _fmt_variant_description(variant, width, indent):
+    """Format a variant's description, preserving explicit line breaks."""
+    return "\n".join(
+        textwrap.fill(
+            line, width=width, initial_indent=indent * " ", subsequent_indent=indent * " "
+        )
+        for line in variant.description.split("\n")
+    )
+
+
+def _fmt_variant(variant, max_name_default_len, indent, when=None, out=None):
+    out = out or sys.stdout
+
+    _, cols = tty.terminal_size()
+
+    name_and_default = _fmt_name_and_default(variant)
+    name_default_len = color.clen(name_and_default)
+
+    values = variant.values
+    if not isinstance(variant.values, (tuple, list, spack.variant.DisjointSetsOfValues)):
+        values = [variant.values]
+
+    # put 'none' first, sort the rest by value
+    sorted_values = sorted(values, key=lambda v: (v != "none", v))
+
+    pad = 4  # min padding between 'name [default]' and values
+    value_indent = (indent + max_name_default_len + pad) * " "  # left edge of values
+
+    # This preserves any formatting (i.e., newlines) from how the description was
+    # written in package.py, but still wraps long lines for small terminals.
+    # This allows some packages to provide detailed help on their variants (see, e.g., gasnet).
+    formatted_values = "\n".join(
+        textwrap.wrap(
+            f"{', '.join(_fmt_value(v) for v in sorted_values)}",
+            width=cols - 2,
+            initial_indent=value_indent,
+            subsequent_indent=value_indent,
+        )
+    )
+    formatted_values = formatted_values[indent + name_default_len + pad :]
+
+    # name [default]   value1, value2, value3, ...
+    padding = pad * " "
+    color.cprint(f"{indent * ' '}{name_and_default}{padding}@c{{{formatted_values}}}", stream=out)
+
+    # when <spec>
+    description_indent = indent + 4
+    if when is not None and when != spack.spec.Spec():
+        out.write(_fmt_when(when, description_indent - 2))
+        out.write("\n")
+
+    # description, preserving explicit line breaks from the way it's written in the package file
+    out.write(_fmt_variant_description(variant, cols - 2, description_indent))
+    out.write("\n")
+
+
+def _variants_by_name_when(pkg):
+    """Adaptor to get variants keyed by { name: { when: { [Variant...] } }."""
+    # TODO: replace with pkg.variants_by_name(when=True) when unified directive dicts are merged.
+    variants = {}
+    for name, (variant, whens) in pkg.variants.items():
+        for when in whens:
+            variants.setdefault(name, {}).setdefault(when, []).append(variant)
+    return variants
+
+
+def _variants_by_when_name(pkg):
+    """Adaptor to get variants keyed by { when: { name: Variant } }"""
+    # TODO: replace with pkg.variants when unified directive dicts are merged.
+    variants = {}
+    for name, (variant, whens) in pkg.variants.items():
+        for when in whens:
+            variants.setdefault(when, {})[name] = variant
+    return variants
+
+
+def _print_variants_header(pkg):
     """output variants"""
 
+    if not pkg.variants:
+        print("    None")
+        return
+
     color.cprint("")
     color.cprint(section_title("Variants:"))
 
-    formatter = VariantFormatter(pkg.variants)
-    for line in formatter.lines:
-        color.cprint(color.cescape(line))
+    variants_by_name = _variants_by_name_when(pkg)
+
+    # Calculate the max length of the "name [default]" part of the variant display
+    # This lets us know where to print variant values.
+    max_name_default_len = max(
+        color.clen(_fmt_name_and_default(variant))
+        for name, when_variants in variants_by_name.items()
+        for variants in when_variants.values()
+        for variant in variants
+    )
+
+    return max_name_default_len, variants_by_name
+
+
+def _unconstrained_ver_first(item):
+    """sort key that puts specs with open version ranges first"""
+    spec, _ = item
+    return (spack.version.any_version not in spec.versions, spec)
+
+
+def print_variants_grouped_by_when(pkg):
+    max_name_default_len, _ = _print_variants_header(pkg)
+
+    indent = 4
+    variants = _variants_by_when_name(pkg)
+    for when, variants_by_name in sorted(variants.items(), key=_unconstrained_ver_first):
+        padded_values = max_name_default_len + 4
+        start_indent = indent
+
+        if when != spack.spec.Spec():
+            sys.stdout.write("\n")
+            sys.stdout.write(_fmt_when(when, indent))
+            sys.stdout.write("\n")
+
+            # indent names slightly inside 'when', but line up values
+            padded_values -= 2
+            start_indent += 2
+
+        for name, variant in sorted(variants_by_name.items()):
+            _fmt_variant(variant, padded_values, start_indent, None, out=sys.stdout)
+
+
+def print_variants_by_name(pkg):
+    max_name_default_len, variants_by_name = _print_variants_header(pkg)
+    max_name_default_len += 4
+
+    indent = 4
+    for name, when_variants in variants_by_name.items():
+        for when, variants in sorted(when_variants.items(), key=_unconstrained_ver_first):
+            for variant in variants:
+                _fmt_variant(variant, max_name_default_len, indent, when, out=sys.stdout)
+                sys.stdout.write("\n")
+
+
+def print_variants(pkg):
+    """output variants"""
+    print_variants_grouped_by_when(pkg)
 
 
 def print_versions(pkg):
@@ -300,18 +430,24 @@ def print_versions(pkg):
         pad = padder(pkg.versions, 4)
 
         preferred = preferred_version(pkg)
-        url = ""
-        if pkg.has_code:
-            url = fs.for_package_version(pkg, preferred)
 
+        def get_url(version):
+            try:
+                return fs.for_package_version(pkg, version)
+            except spack.fetch_strategy.InvalidArgsError:
+                return "No URL"
+
+        url = get_url(preferred) if pkg.has_code else ""
         line = version("    {0}".format(pad(preferred))) + color.cescape(url)
-        color.cprint(line)
+        color.cwrite(line)
+
+        print()
 
         safe = []
         deprecated = []
         for v in reversed(sorted(pkg.versions)):
             if pkg.has_code:
-                url = fs.for_package_version(pkg, v)
+                url = get_url(v)
             if pkg.versions[v].get("deprecated", False):
                 deprecated.append((v, url))
             else:
@@ -384,7 +520,12 @@ def info(parser, args):
     else:
         color.cprint("    None")
 
-    color.cprint(section_title("Homepage: ") + pkg.homepage)
+    if getattr(pkg, "homepage"):
+        color.cprint(section_title("Homepage: ") + pkg.homepage)
+
+    _print_variants = (
+        print_variants_by_name if args.variants_by_name else print_variants_grouped_by_when
+    )
 
     # Now output optional information in expected order
     sections = [
@@ -392,7 +533,7 @@ def info(parser, args):
         (args.all or args.detectable, print_detectable),
         (args.all or args.tags, print_tags),
         (args.all or not args.no_versions, print_versions),
-        (args.all or not args.no_variants, print_variants),
+        (args.all or not args.no_variants, _print_variants),
         (args.all or args.phases, print_phases),
         (args.all or not args.no_dependencies, print_dependencies),
         (args.all or args.virtuals, print_virtuals),


Test patch:
diff --git a/lib/spack/spack/test/cmd/info.py b/lib/spack/spack/test/cmd/info.py
--- a/lib/spack/spack/test/cmd/info.py
+++ b/lib/spack/spack/test/cmd/info.py
@@ -25,7 +25,7 @@ def parser():
 def print_buffer(monkeypatch):
     buffer = []
 
-    def _print(*args):
+    def _print(*args, **kwargs):
         buffer.extend(args)
 
     monkeypatch.setattr(spack.cmd.info.color, "cprint", _print, raising=False)