Repository: mne-tools/mne-python

Issue:
Do not list not installed packages in `mne.sys_info()`
We would like to see the output of `mne.sys_info()` in issue reports, but this list is very long – even when many packages are not installed it looks like this:

```
Platform:         macOS-13.2.1-arm64-arm-64bit
Python:           3.10.10 (v3.10.10:aad5f6a891, Feb  7 2023, 08:47:40) [Clang 13.0.0 (clang-1300.0.29.30)]
Executable:       /Users/clemens/.local/pipx/venvs/ipython/bin/python
CPU:              arm: 12 cores
Memory:           32.0 GB

mne:              1.4.dev0
numpy:            1.24.2 {OpenBLAS 0.3.21 with 12 threads}
scipy:            1.10.1
matplotlib:       3.7.0 {backend=MacOSX}

sklearn:          Not found
numba:            Not found
nibabel:          Not found
nilearn:          Not found
dipy:             Not found
openmeeg:         Not found
cupy:             Not found
pandas:           Not found
pyvista:          Not found
pyvistaqt:        Not found
ipyvtklink:       Not found
vtk:              Not found
qtpy:             Not found
ipympl:           Not found
pyqtgraph:        Not found
pooch:            v1.7.0

mne_bids:         Not found
mne_nirs:         Not found
mne_features:     Not found
mne_qt_browser:   Not found
mne_connectivity: Not found
mne_icalabel:     Not found
```

I propose to exclude packages that are not installed, so the list would be much shorter:

```
Platform:         macOS-13.2.1-arm64-arm-64bit
Python:           3.10.10 (v3.10.10:aad5f6a891, Feb  7 2023, 08:47:40) [Clang 13.0.0 (clang-1300.0.29.30)]
Executable:       /Users/clemens/.local/pipx/venvs/ipython/bin/python
CPU:              arm: 12 cores
Memory:           32.0 GB

mne:              1.4.dev0
numpy:            1.24.2 {OpenBLAS 0.3.21 with 12 threads}
scipy:            1.10.1
matplotlib:       3.7.0 {backend=MacOSX}

pooch:            v1.7.0
```


Discussion:
maybe use a flat list for packages not installed? I think it is still
valuable

> Message ID: ***@***.***>
>

Yes, that's probably a good idea – we've been discussing this directly in #11544.

Resolving patch:
diff --git a/mne/commands/mne_sys_info.py b/mne/commands/mne_sys_info.py
--- a/mne/commands/mne_sys_info.py
+++ b/mne/commands/mne_sys_info.py
@@ -23,13 +23,20 @@ def run():
     parser.add_option('-d', '--developer', dest='developer',
                       help='Show additional developer module information',
                       action='store_true')
+    parser.add_option('-a', '--ascii', dest='unicode',
+                      help='Use ASCII instead of unicode symbols',
+                      action='store_false', default=True)
     options, args = parser.parse_args()
     dependencies = 'developer' if options.developer else 'user'
     if len(args) != 0:
         parser.print_help()
         sys.exit(1)
 
-    mne.sys_info(show_paths=options.show_paths, dependencies=dependencies)
+    mne.sys_info(
+        show_paths=options.show_paths,
+        dependencies=dependencies,
+        unicode=options.unicode
+    )
 
 
 mne.utils.run_command_if_main()
diff --git a/mne/utils/config.py b/mne/utils/config.py
--- a/mne/utils/config.py
+++ b/mne/utils/config.py
@@ -6,6 +6,7 @@
 
 import atexit
 import json
+import multiprocessing
 import os
 import os.path as op
 import platform
@@ -15,6 +16,7 @@
 import sys
 import tempfile
 from functools import partial
+from importlib import import_module
 from pathlib import Path
 
 from .check import (_validate_type, _check_qt_version, _check_option,
@@ -500,56 +502,26 @@ def _get_gpu_info():
     return out
 
 
-def sys_info(fid=None, show_paths=False, *, dependencies='user'):
-    """Print the system information for debugging.
+def sys_info(fid=None, show_paths=False, *, dependencies='user', unicode=True):
+    """Print system information.
 
-    This function is useful for printing system information
-    to help triage bugs.
+    This function prints system information useful when triaging bugs.
 
     Parameters
     ----------
     fid : file-like | None
-        The file to write to. Will be passed to :func:`print()`.
-        Can be None to use :data:`sys.stdout`.
+        The file to write to. Will be passed to :func:`print()`. Can be None to
+        use :data:`sys.stdout`.
     show_paths : bool
         If True, print paths for each module.
     dependencies : 'user' | 'developer'
         Show dependencies relevant for users (default) or for developers
         (i.e., output includes additional dependencies).
+    unicode : bool
+        Include Unicode symbols in output.
 
         .. versionadded:: 0.24
-
-    Examples
-    --------
-    Running this function with no arguments prints an output that is
-    useful when submitting bug reports::
-
-        >>> import mne
-        >>> mne.sys_info() # doctest: +SKIP
-        Platform:      Linux-4.15.0-1067-aws-x86_64-with-glibc2.2.5
-        Python:        3.8.1 (default, Feb  2 2020, 08:37:37)  [GCC 8.3.0]
-        Executable:    /usr/local/bin/python
-        CPU:           : 36 cores
-        Memory:        68.7 GB
-
-        mne:           0.21.dev0
-        numpy:         1.19.0 {blas=openblas, lapack=openblas}
-        scipy:         1.5.1
-        matplotlib:    3.2.2 {backend=Qt5Agg}
-
-        sklearn:       0.23.1
-        numba:         0.50.1
-        nibabel:       3.1.1
-        nilearn:       0.7.0
-        dipy:          1.1.1
-        cupy:          Not found
-        pandas:        1.0.5
-        pyvista:       0.25.3 {pyvistaqt=0.1.1, OpenGL 3.3 (Core Profile) Mesa 18.3.6 via llvmpipe (LLVM 7.0, 256 bits)}
-        vtk:           9.0.1
-        qtpy:          2.0.1 {PySide6=6.2.4}
-        pyqtgraph:     0.12.4
-        pooch:         v1.5.1
-    """  # noqa: E501
+    """
     _validate_type(dependencies, str)
     _check_option('dependencies', dependencies, ('user', 'developer'))
     ljust = 21 if dependencies == 'developer' else 18
@@ -569,14 +541,8 @@ def sys_info(fid=None, show_paths=False, *, dependencies='user'):
     out('Platform:'.ljust(ljust) + platform_str + '\n')
     out('Python:'.ljust(ljust) + str(sys.version).replace('\n', ' ') + '\n')
     out('Executable:'.ljust(ljust) + sys.executable + '\n')
-    out('CPU:'.ljust(ljust) + f'{platform.processor()}: ')
-    try:
-        import multiprocessing
-    except ImportError:
-        out('number of processors unavailable '
-            '(requires "multiprocessing" package)\n')
-    else:
-        out(f'{multiprocessing.cpu_count()} cores\n')
+    out('CPU:'.ljust(ljust) + f'{platform.processor()} ')
+    out(f'({multiprocessing.cpu_count()} cores)\n')
     out('Memory:'.ljust(ljust))
     try:
         import psutil
@@ -586,26 +552,63 @@ def sys_info(fid=None, show_paths=False, *, dependencies='user'):
         out(f'{psutil.virtual_memory().total / float(2 ** 30):0.1f} GB\n')
     out('\n')
     libs = _get_numpy_libs()
-    use_mod_names = ('mne', 'numpy', 'scipy', 'matplotlib', '', 'sklearn',
-                     'numba', 'nibabel', 'nilearn', 'dipy', 'openmeeg', 'cupy',
-                     'pandas', 'pyvista', 'pyvistaqt', 'ipyvtklink', 'vtk',
-                     'qtpy', 'ipympl', 'pyqtgraph', 'pooch', '', 'mne_bids',
-                     'mne_nirs', 'mne_features', 'mne_qt_browser',
-                     'mne_connectivity', 'mne_icalabel')
+    unavailable = []
+    use_mod_names = (
+        '# Core',
+        'mne', 'numpy', 'scipy', 'matplotlib', 'pooch', 'jinja2',
+        '',
+        '# Numerical (optional)',
+        'sklearn', 'numba', 'nibabel', 'nilearn', 'dipy', 'openmeeg', 'cupy',
+        'pandas',
+        '',
+        '# Visualization (optional)',
+        'pyvista', 'pyvistaqt', 'ipyvtklink', 'vtk', 'qtpy', 'ipympl',
+        'pyqtgraph', 'mne-qt-browser',
+        '',
+        '# Ecosystem (optional)',
+        'mne_bids', 'mne_nirs', 'mne_features', 'mne_connectivity',
+        'mne_icalabel',
+        ''
+    )
     if dependencies == 'developer':
         use_mod_names += (
-            '', 'sphinx', 'sphinx_gallery', 'numpydoc', 'pydata_sphinx_theme',
-            'pytest', 'nbclient')
-    for mod_name in use_mod_names:
-        if mod_name == '':
-            out('\n')
+            '# Testing',
+            'pytest', 'nbclient', 'numpydoc', 'flake8', 'pydocstyle',
+            '',
+            '# Documentation',
+            'sphinx', 'sphinx_gallery', 'pydata_sphinx_theme',
+            '',
+        )
+    try:
+        unicode = unicode and (sys.stdout.encoding.lower().startswith('utf'))
+    except Exception:  # in case someone overrides sys.stdout in an unsafe way
+        unicode = False
+    for mi, mod_name in enumerate(use_mod_names):
+        # upcoming break
+        if mod_name == '':  # break
+            if unavailable:
+                out('└☐ ' if unicode else ' - ')
+                out('unavailable:'.ljust(ljust))
+                out(f"{', '.join(unavailable)}\n")
+                unavailable = []
+            if mi != len(use_mod_names) - 1:
+                out('\n')
+            continue
+        elif mod_name.startswith('# '):  # header
+            mod_name = mod_name.replace('# ', '')
+            out(f'{mod_name}\n')
             continue
-        out(f'{mod_name}:'.ljust(ljust))
+        pre = '├'
+        last = use_mod_names[mi + 1] == '' and not unavailable
+        if last:
+            pre = '└'
         try:
-            mod = __import__(mod_name)
+            mod = import_module(mod_name)
         except Exception:
-            out('Not found\n')
+            unavailable.append(mod_name)
         else:
+            out(f'{pre}☑ ' if unicode else ' + ')
+            out(f'{mod_name}:'.ljust(ljust))
             if mod_name == 'vtk':
                 vtk_version = mod.vtkVersion()
                 # 9.0 dev has VersionFull but 9.0 doesn't
@@ -618,20 +621,26 @@ def sys_info(fid=None, show_paths=False, *, dependencies='user'):
                 else:
                     out('unknown')
             else:
-                out(mod.__version__)
+                out(mod.__version__.lstrip("v"))
             if mod_name == 'numpy':
-                out(f' {{{libs}}}')
+                out(f' ({libs})')
             elif mod_name == 'qtpy':
                 version, api = _check_qt_version(return_api=True)
-                out(f' {{{api}={version}}}')
+                out(f' ({api}={version})')
             elif mod_name == 'matplotlib':
-                out(f' {{backend={mod.get_backend()}}}')
+                out(f' (backend={mod.get_backend()})')
             elif mod_name == 'pyvista':
                 version, renderer = _get_gpu_info()
                 if version is None:
-                    out(' {OpenGL could not be initialized}')
+                    out(' (OpenGL unavailable)')
                 else:
-                    out(f' {{OpenGL {version} via {renderer}}}')
+                    out(f' (OpenGL {version} via {renderer})')
             if show_paths:
-                out(f'\n{" " * ljust}•{op.dirname(mod.__file__)}')
+                if last:
+                    pre = '   '
+                elif unicode:
+                    pre = '│  '
+                else:
+                    pre = ' | '
+                out(f'\n{pre}{" " * ljust}{op.dirname(mod.__file__)}')
             out('\n')


Test patch: