Repository: mesonbuild/meson

Issue:
Enhancement: configuration summary printing
Many configure scripts have something like this at the end:

```
echo $PACKAGE configuration summary:
echo
echo Installation Prefix .............. : ${prefix}
echo GStreamer API version ............ : $GST_VERSION
echo VA-API version ................... : $VA_VERSION_STR
echo Video encoding ................... : $(yesno $USE_ENCODERS)
echo Video outputs .................... : $VIDEO_OUTPUTS
```

It would be nice if we had dedicated API for this in Meson. It would provide two features:
- booleans printed as yes|no by default, though possibility to pass alternative strings would be nice to have too
- formatting the output so that it's nicely in two columns (so would have to collect all config summary statements to figure out width first)

API-wise it could be something like:

```
summary_message('Foo', value)
summary_message('Bar', other_value)
```

or

```
print_summary([
  ['Foo', value],
  ['Bar', value],
])
```



Discussion:
Although I do agree that the proposed approach may cover almost all cases, there are some different ones.

Based on my what I've seen on different packages, usually the summary prints different options values which are split in different sections. There are also some packages that might also show messages with different values.

For example:

```
System paths:
  prefix: /tmp/nm
  exec_prefix: ${prefix}
  systemdunitdir: /tmp/nm/lib/system/system
  nmbinary: ${sbindir}/NetworkManager
  nmconfdir: ${sysconfdir}/NetworkManager
  nmlibdir: ${prefix}/lib/NetworkManager
  nmdatadir: ${datadir}/NetworkManager
  nmstatedir: ${localstatedir}/lib/NetworkManager
  nmrundir: ${runstatedir}/NetworkManager

Platform:
  session tracking: systemd-logind, consolekit
  suspend/resume: systemd
  policykit: yes (permissive modify.system) (default: main.auth-polkit=yes)
  polkit agent: yes
  selinux: yes
  systemd-journald: yes (default: logging.backend=journal)
  hostname persist: default
  libaudit: yes (default: logging.audit=true)

Features:
  wext: yes
  wifi: yes
  ppp: yes ${exec_prefix}/lib/pppd/2.4.5
  modemmanager-1: yes
  ofono: yes
  concheck: yes
  libteamdctl: yes
  ovs: yes
  libnm-glib: yes
  nmcli: yes
  ...
```

Following the first proposal, I would extend it with the following changes:

```
summary_message('Foo', [value1, value2])
summary_section('FooBar')
summary_message('Bar', [value1, value2, value3])
```

As you can see, some values has also descriptions (`(default: logging.backend=journal)`) which is not covered by this extension.

Using the other proposal would be something like this:

```
print_summary([
  ['Foo', foo_options],
  ['FooBar, ['Bar', bar_options]]
])
```
Also for consideration, is how to manage printing out of lists of components, potentially with 20+ items in the list. For example, in DPDK, we have dozens of drivers in multiple categories, which would make listing them vertically as yes/no infeasible. Therefore I think we may want to have different types of summary sections, for example, "yes-no", "name-value" or "component-list". The former two are shown as above, while latter would be configured by e.g:

    foo=summary_section('Foo Components', type: list)
    foreach item:components
       ...
       if build
          add_summary_component(foo, item)
       endif
    endforeach

Then at the end of the meson run the summary list would include a horiztonal list, word-wrapped appropriately:

    Foo Components:
        comp1 comp2 comp3 ... compX
        compX+1 ..... compN

I'm also assuming that the APIs are designed for calling throughout the various meson files, and that summary printing is only done at the end?
Something like that yes but preferably something simpler:

```meson
delayed_status_message('Some setting', 'its value')
```

I think the concept of summaries being sub-divided into sections of sort is an essential requirement, so something super-simple probably won't cut it. This is the kind of thing that starts out simple but may need to be tweaked and extended later, so doing something object-based from the start might make sense?

```
s = summary()
platform = s.add_section('Platform')
platform.add('selinux', opt_selinux)
s.print()
```

Alternatively, one could do away with the `summary()` object and have it implicit in Meson:
```
platform = add_summary_section('Platform')
platform.add('selinux', opt_selinux)
```

or just use
```
platform = meson.add_summary_section('Platform')
platform.add('selinux', opt_selinux)
```
instead and rely on Meson printing it automatically at the end if any have been registered.

I would assume that anything related to this API is either automatically delayed to the end or to the moment some `.print()` or `.summarise()` function is called, so don't think that would have to be part of the API.
Or possibly something like:

```meson
final_status_message(type : 'Thingy name',
  value : 'its value',
  section : 'Platform')
```

Section can be empty, all those would be grouped in one.
Maybe a bit crazy idea, but I'm wondering if this summary message could be partly automatic, based on 'feature' options. Most of the time what's interesting to know in the summary is if it found "auto" features, right?

When doing `dependency('foo', required : get_option('an-auto-feature-option'))` we could remember if the result was found and generate a report at the end for each option of type feature.
I've gotten multiple requests for this feature so I'm looking into adding it. What about using a dict for the summary?
Not sure if doing something just based on feature options is enough. There are many things which are not decided/covered by feature options (manual detection of things; combos etc.).

@dcbaker: How would a dict work exactly? Would it be a dict per section or a dict with sub-dicts or?

I still think explicit/non-clever API is best, because whatever we do we will inevitably want to extend it in future. But I haven't seen how you want to use dicts of course. Can you give examples?
I agree, non-clever is best: I'm imagining something like:

```meson
sec1 = {'foo' : true, 'bar' : 'thing'}
sec2 = { 'boo' : 1, 'arb' : ['some', 'things']}
summary(
  'Section 1 options' : sec1,
  'Section 2 options' : sec2,
)
```
With summary taking keyword arguments for each section, with the argument as the name, and it would print something like:

```txt
Section 1 options:
  foo = true
  bar = 'thing'

Section 2 options:
  boo = 1
  arb = ['some', 'things']
```
Or better yet, just use a single nested dictionary:

```meson
sec1 = {'foo' : true, 'bar' : 'thing'}
sec2 = { 'boo' : 1, 'arb' : ['some', 'things']}
summary({
  'Section 1 options' : sec1,
  'Section 2 options' : sec2,
})
```

Have I mentioned it's annoying that lists are mutable but dicts are immutable?
I think it should also be okay to pass an unnested dict which is probably useful for simpler projects.
There is also #4643...
I'm not opposed to 4643, but I like my approach better :)

For my use, 100% of the reason to have this is to print the configuration summary at the end of the configuration, (what auto options got selected, what's turned on, etc). From that point of view building a data structure (dict) of key value pairs and letting meson do the formatting for me is better than having to manually do the formatting in strings.

Also different, though possibly solvable, is that my solution integrates subproject messages into the final summary, but separates them in a way that is (hopefully) clear.

Oh, and mine makes it easy to group like things because it doesn't depend on the order that the function is called in. even with `end_message` I'm almost certainly going to have to store all of the messages anyway and loop over them at the end of the program due to the complex nature of mesa.

I'm going to go try both of them in mesa and see how they work out.
Okay, I wired up prototypes of both in mesa, and here's my take away:
My `summary` approach required a little more typing, but was much more flexible. In particular it allows you to set the state as you go, even when the order of the meson.build file doesn't match the order you want the summary printed in. The `end_message` approach was less typing overall, but proved to be awkward to work with, as it prints messages in the order it's called in, which means you either end up tracking state or just calling `end_message at the end of the meson.build anyway (I ended up with the latter).

In mesa I want to group the summary into groups like "core options", "dri drivers", "gallium", "vulkan drivers", "window systems", "llvm", but some of those thing are intermixed. When you use an auto option, for example, we might need to figure out the drivers, then the platforms, then gallium state trackers (dx, opengl, opencl, media). Or I might figure out the dri drivers, then the gallium drivers, then other dri settings.

Here's the two patches for reference:
[summary](https://gitlab.freedesktop.org/dbaker/mesa/commit/1151890d8daccc91fd0a4ae1baca2759140f2743)
[end_message](https://gitlab.freedesktop.org/dbaker/mesa/commit/0fc1e1a381e03f0fd086ea9a8933736a0e8dcd22)
The `end_message()` idea has a certain simplicity, but I don't know if it can deliver on what I was after.

Half the job of the function is formatting IMHO, nice indentation, perhaps even column spacing, maybe more. I quite like the `summary()` suggestion, but haven't tried to put it into use yet.
The `summary()` implementation needs a bit more work ATM. It's basically just enough POC to try the API and some very basic formatting. I'm planning to spend a bit more time after lunch polishing it.

Resolving patch:
diff --git a/mesonbuild/ast/interpreter.py b/mesonbuild/ast/interpreter.py
--- a/mesonbuild/ast/interpreter.py
+++ b/mesonbuild/ast/interpreter.py
@@ -119,6 +119,7 @@ def __init__(self, source_root: str, subdir: str, visitors: Optional[List[AstVis
                            'find_library': self.func_do_nothing,
                            'subdir_done': self.func_do_nothing,
                            'alias_target': self.func_do_nothing,
+                           'summary': self.func_do_nothing,
                            })
 
     def func_do_nothing(self, node, args, kwargs):
diff --git a/mesonbuild/interpreter.py b/mesonbuild/interpreter.py
--- a/mesonbuild/interpreter.py
+++ b/mesonbuild/interpreter.py
@@ -38,7 +38,7 @@
 import os, shutil, uuid
 import re, shlex
 import subprocess
-from collections import namedtuple
+import collections
 from itertools import chain
 import functools
 from typing import Sequence, List, Union, Optional, Dict, Any
@@ -1691,7 +1691,7 @@ def get_argument_syntax_method(self, args, kwargs):
         return self.compiler.get_argument_syntax()
 
 
-ModuleState = namedtuple('ModuleState', [
+ModuleState = collections.namedtuple('ModuleState', [
     'source_root', 'build_to_src', 'subproject', 'subdir', 'current_lineno', 'environment',
     'project_name', 'project_version', 'backend', 'targets',
     'data', 'headers', 'man', 'global_args', 'project_args', 'build_machine',
@@ -1751,6 +1751,48 @@ def method_call(self, method_name, args, kwargs):
                 raise InterpreterException('Extension module altered internal state illegally.')
             return self.interpreter.module_method_callback(value)
 
+
+class Summary:
+    def __init__(self, project_name, project_version):
+        self.project_name = project_name
+        self.project_version = project_version
+        self.sections = collections.defaultdict(dict)
+        self.max_key_len = 0
+
+    def add_section(self, section, values, kwargs):
+        bool_yn = kwargs.get('bool_yn', False)
+        if not isinstance(bool_yn, bool):
+            raise InterpreterException('bool_yn keyword argument must be boolean')
+        for k, v in values.items():
+            if k in self.sections[section]:
+                raise InterpreterException('Summary section {!r} already have key {!r}'.format(section, k))
+            formatted_values = []
+            for i in listify(v):
+                if not isinstance(i, (str, int)):
+                    m = 'Summary value in section {!r}, key {!r}, must be string, integer or boolean'
+                    raise InterpreterException(m.format(section, k))
+                if bool_yn and isinstance(i, bool):
+                    formatted_values.append(mlog.green('YES') if i else mlog.red('NO'))
+                else:
+                    formatted_values.append(i)
+            self.sections[section][k] = formatted_values
+            self.max_key_len = max(self.max_key_len, len(k))
+
+    def dump(self):
+        mlog.log(self.project_name, mlog.normal_cyan(self.project_version))
+        for section, values in self.sections.items():
+            mlog.log('')  # newline
+            if section:
+                mlog.log(' ', mlog.bold(section))
+            for k, v in values.items():
+                indent = self.max_key_len - len(k) + 3
+                mlog.log(' ' * indent, k + ':', v[0])
+                indent = self.max_key_len + 5
+                for i in v[1:]:
+                    mlog.log(' ' * indent, i)
+        mlog.log('')  # newline
+
+
 class MesonMain(InterpreterObject):
     def __init__(self, build, interpreter):
         InterpreterObject.__init__(self)
@@ -2078,6 +2120,7 @@ def __init__(self, build, backend=None, subproject='', subdir='', subproject_dir
         self.coredata = self.environment.get_coredata()
         self.backend = backend
         self.subproject = subproject
+        self.summary = {}
         if modules is None:
             self.modules = {}
         else:
@@ -2188,6 +2231,7 @@ def build_func_dict(self):
                            'subdir': self.func_subdir,
                            'subdir_done': self.func_subdir_done,
                            'subproject': self.func_subproject,
+                           'summary': self.func_summary,
                            'shared_library': self.func_shared_lib,
                            'shared_module': self.func_shared_module,
                            'static_library': self.func_static_lib,
@@ -2485,15 +2529,18 @@ def func_subproject(self, nodes, args, kwargs):
         dirname = args[0]
         return self.do_subproject(dirname, 'meson', kwargs)
 
-    def disabled_subproject(self, dirname):
-        self.subprojects[dirname] = SubprojectHolder(None, self.subproject_dir, dirname)
-        return self.subprojects[dirname]
+    def disabled_subproject(self, dirname, feature=None):
+        sub = SubprojectHolder(None, self.subproject_dir, dirname)
+        if feature:
+            sub.disabled_feature = feature
+        self.subprojects[dirname] = sub
+        return sub
 
     def do_subproject(self, dirname: str, method: str, kwargs):
         disabled, required, feature = extract_required_kwarg(kwargs, self.subproject)
         if disabled:
             mlog.log('Subproject', mlog.bold(dirname), ':', 'skipped: feature', mlog.bold(feature), 'disabled')
-            return self.disabled_subproject(dirname)
+            return self.disabled_subproject(dirname, feature)
 
         default_options = mesonlib.stringlistify(kwargs.get('default_options', []))
         default_options = coredata.create_options_dict(default_options)
@@ -2594,6 +2641,7 @@ def _do_subproject_meson(self, dirname, subdir, default_options, kwargs, ast=Non
             self.build_def_files = list(set(self.build_def_files + subi.build_def_files))
         self.build.merge(subi.build)
         self.build.subprojects[dirname] = subi.project_version
+        self.summary.update(subi.summary)
         return self.subprojects[dirname]
 
     def _do_subproject_cmake(self, dirname, subdir, subdir_abs, default_options, kwargs):
@@ -2830,6 +2878,57 @@ def func_message(self, node, args, kwargs):
     def message_impl(self, argstr):
         mlog.log(mlog.bold('Message:'), argstr)
 
+    @noArgsFlattening
+    @permittedKwargs({'bool_yn'})
+    @FeatureNew('summary', '0.53.0')
+    def func_summary(self, node, args, kwargs):
+        if len(args) == 1:
+            if not isinstance(args[0], dict):
+                raise InterpreterException('Argument 1 must be a dictionary.')
+            section = ''
+            values = args[0]
+        elif len(args) == 2:
+            if not isinstance(args[0], str):
+                raise InterpreterException('Argument 1 must be a string.')
+            if isinstance(args[1], dict):
+                section, values = args
+            else:
+                section = ''
+                values = {args[0]: args[1]}
+        elif len(args) == 3:
+            if not isinstance(args[0], str):
+                raise InterpreterException('Argument 1 must be a string.')
+            if not isinstance(args[1], str):
+                raise InterpreterException('Argument 2 must be a string.')
+            section, key, value = args
+            values = {key: value}
+        else:
+            raise InterpreterException('Summary accepts at most 3 arguments.')
+        self.summary_impl(section, values, kwargs)
+
+    def summary_impl(self, section, values, kwargs):
+        if self.subproject not in self.summary:
+            self.summary[self.subproject] = Summary(self.active_projectname, self.project_version)
+        self.summary[self.subproject].add_section(section, values, kwargs)
+
+    def _print_summary(self):
+        # Add automatic 'Supbrojects' section in main project.
+        all_subprojects = collections.OrderedDict()
+        for name, subp in sorted(self.subprojects.items()):
+            value = subp.found()
+            if not value and hasattr(subp, 'disabled_feature'):
+                value = 'Feature {!r} disabled'.format(subp.disabled_feature)
+            all_subprojects[name] = value
+        if all_subprojects:
+            self.summary_impl('Subprojects', all_subprojects, {'bool_yn': True})
+        # Print all summaries, main project last.
+        mlog.log('')  # newline
+        main_summary = self.summary.pop('', None)
+        for _, summary in sorted(self.summary.items()):
+            summary.dump()
+        if main_summary:
+            main_summary.dump()
+
     @FeatureNew('warning', '0.44.0')
     @noKwargs
     def func_warning(self, node, args, kwargs):
@@ -4070,6 +4169,8 @@ def run(self):
         FeatureDeprecated.report(self.subproject)
         if not self.is_subproject():
             self.print_extra_warnings()
+        if self.subproject == '':
+            self._print_summary()
 
     def print_extra_warnings(self):
         # TODO cross compilation
diff --git a/run_unittests.py b/run_unittests.py
--- a/run_unittests.py
+++ b/run_unittests.py
@@ -4113,6 +4113,40 @@ def test_configure(self):
         self.init(testdir)
         self._run(self.mconf_command + [self.builddir])
 
+    # FIXME: The test is failing on Windows CI even if the print looks good.
+    # Maybe encoding issue?
+    @unittest.skipIf(is_windows(), 'This test fails on Windows CI')
+    def test_summary(self):
+        testdir = os.path.join(self.unit_test_dir, '74 summary')
+        out = self.init(testdir)
+        expected = textwrap.dedent(r'''
+            Some Subproject 2.0
+
+                 string: bar
+                integer: 1
+                boolean: True
+
+            My Project 1.0
+
+              Configuration
+                   Some boolean: False
+                Another boolean: True
+                    Some string: Hello World
+                         A list: string
+                                 1
+                                 True
+                       A number: 1
+                            yes: YES
+                             no: NO
+            ''')
+        # Dict ordering is not guaranteed and an exact string comparison randomly
+        # fails on the CI because lines are reordered.
+        expected_lines = expected.split('\n')[1:]
+        out_start = out.find(expected_lines[0])
+        out_lines = out[out_start:].split('\n')[:len(expected_lines)]
+        self.assertEqual(sorted(expected_lines), sorted(out_lines))
+
+
 class FailureTests(BasePlatformTests):
     '''
     Tests that test failure conditions. Build files here should be dynamically


Test patch:
diff --git a/test cases/unit/74 summary/meson.build b/test cases/unit/74 summary/meson.build
new file mode 100644
--- /dev/null
+++ b/test cases/unit/74 summary/meson.build	
@@ -0,0 +1,13 @@
+project('My Project', version : '1.0')
+
+subproject('sub')
+subproject('sub2', required : false)
+
+summary('Configuration', {'Some boolean': false,
+                          'Another boolean': true,
+                          'Some string': 'Hello World',
+                          'A list': ['string', 1, true],
+                          })
+summary('Configuration', 'A number', 1)
+summary('Configuration', 'yes', true, bool_yn : true)
+summary('Configuration', 'no', false, bool_yn : true)
diff --git a/test cases/unit/74 summary/subprojects/sub/meson.build b/test cases/unit/74 summary/subprojects/sub/meson.build
new file mode 100644
--- /dev/null
+++ b/test cases/unit/74 summary/subprojects/sub/meson.build	
@@ -0,0 +1,4 @@
+project('Some Subproject', version : '2.0')
+
+summary('string', 'bar')
+summary({'integer': 1, 'boolean': true})
diff --git a/test cases/unit/74 summary/subprojects/sub2/meson.build b/test cases/unit/74 summary/subprojects/sub2/meson.build
new file mode 100644
--- /dev/null
+++ b/test cases/unit/74 summary/subprojects/sub2/meson.build	
@@ -0,0 +1,5 @@
+project('sub2')
+
+error('This subproject failed')
+
+summary('Section', 'Should not be seen')