Repository: SCons/scons

Issue:
Improve json output of Dump
json-formatted output was added  to `env.Dump()` fairly recently. Unfortunately, SCons datatypes that appear in `env`'s dictionary of construction variables are not serialized by the json module's encoder, instead the `default` function just prints the class name (actually `__qualname__` rather than `__name__`). So in the first pass, we got:
```
    "BUILDERS": "BuilderDict",
    "SCANNERS": [
        "ScannerBase"
    ],
```
PR #4480 updated this to the more visually apparent:
```
    "BUILDERS": "<<non-serializable: BuilderDict>>",
    "SCANNERS": [
        "<<non-serializable: ScannerBase>>"
    ],
```

But we should be able to do better. `BUILDERS` is a dictionary (`BuilderDict` is a subclass of `UserDict`) which should show all the configured builders in `env`.  Construction variables that are of type `CLVar` (a subclass of `UserList`) are lists of options to be passed on to the command they are associated with, and it would be more useful if the contents of those lists are shown, as they are in the default output format of `Dump`.

It turns out that `json.JSONEncoder` is intended to be subclassed to extend the behavior:

> To use a custom [`JSONEncoder`](https://docs.python.org/3/library/json.html#json.JSONEncoder) subclass (e.g. one that overrides the `default()` method to serialize additional types), specify it with the `cls` kwarg; otherwise [`JSONEncoder`](https://docs.python.org/3/library/json.html#json.JSONEncoder) is used.

But in fact even that isn't necessary: we already pass our own function to use instead of `default`, and it could easily recognize objects that are derived from `UserDict` and `UserList` and just return those twos `.data` attribute if so.

As an additional note: the output format using the Python `pprint` module (the default format) sorts the keys, the JSON format can do so as well, if passed the `sort_keys=True` argument.



Discussion:

Resolving patch:
diff --git a/SCons/Environment.py b/SCons/Environment.py
--- a/SCons/Environment.py
+++ b/SCons/Environment.py
@@ -35,9 +35,9 @@
 import sys
 import re
 import shlex
-from collections import UserDict, deque
+from collections import UserDict, UserList, deque
 from subprocess import PIPE, DEVNULL
-from typing import Optional
+from typing import Optional, Sequence
 
 import SCons.Action
 import SCons.Builder
@@ -1687,17 +1687,23 @@ def Dictionary(self, *args):
         return dlist
 
 
-    def Dump(self, key=None, format: str='pretty'):
-        """ Return construction variables serialized to a string.
+    def Dump(self, key: Optional[str] = None, format: str = 'pretty') -> str:
+        """ Returns a dump of serialized construction variables.
+
+        The display formats are intended for humaan readers when
+        debugging - none of the supported formats produce a result that
+        SCons itself can directly make use of. Objects that cannot
+        directly be represented get a placeholder like
+        ``<function foo at 0x123456>`` or ``<<non-serializable: function>>``.
 
         Args:
-          key (optional): if None, format the whole dict of variables.
-            Else format the value of `key` (Default value = None)
-          format (str, optional): specify the format to serialize to.
-            `"pretty"` generates a pretty-printed string,
-            `"json"` a JSON-formatted string.
-            (Default value = `"pretty"`)
+           key: if ``None``, format the whole dict of variables,
+              else format just the value of *key*.
+           format: specify the format to serialize to. ``"pretty"`` generates
+             a pretty-printed string, ``"json"`` a JSON-formatted string.
 
+        Raises:
+           ValueError: *format* is not a recognized serialization format.
         """
         if key:
             cvars = self.Dictionary(key)
@@ -1707,9 +1713,9 @@ def Dump(self, key=None, format: str='pretty'):
         fmt = format.lower()
 
         if fmt == 'pretty':
-            import pprint
-            pp = pprint.PrettyPrinter(indent=2)
+            import pprint  # pylint: disable=import-outside-toplevel
 
+            pp = pprint.PrettyPrinter(indent=2)
             # TODO: pprint doesn't do a nice job on path-style values
             # if the paths contain spaces (i.e. Windows), because the
             # algorithm tries to break lines on spaces, while breaking
@@ -1718,26 +1724,33 @@ def Dump(self, key=None, format: str='pretty'):
             return pp.pformat(cvars)
 
         elif fmt == 'json':
-            import json
-            def non_serializable(obj):
-                return '<<non-serializable: %s>>' % type(obj).__qualname__
-            return json.dumps(cvars, indent=4, default=non_serializable)
+            import json  # pylint: disable=import-outside-toplevel
+
+            class DumpEncoder(json.JSONEncoder):
+                """SCons special json Dump formatter."""
+                def default(self, obj):
+                    if isinstance(obj, (UserList, UserDict)):
+                        return obj.data
+                    return f'<<non-serializable: {type(obj).__qualname__}>>'
+
+            return json.dumps(cvars, indent=4, cls=DumpEncoder, sort_keys=True)
         else:
             raise ValueError("Unsupported serialization format: %s." % fmt)
 
 
-    def FindIxes(self, paths, prefix, suffix):
-        """Search a list of paths for something that matches the prefix and suffix.
+    def FindIxes(self, paths: Sequence[str], prefix: str, suffix: str) -> Optional[str]:
+        """Search *paths* for a path that has *prefix* and *suffix*.
 
-        Args:
-          paths: the list of paths or nodes.
-          prefix: construction variable for the prefix.
-          suffix: construction variable for the suffix.
+        Returns on first match.
 
-        Returns: the matched path or None
+        Arguments:
+           paths: the list of paths or nodes.
+           prefix: construction variable for the prefix.
+           suffix: construction variable for the suffix.
 
+        Returns:
+           The matched path or ``None``
         """
-
         suffix = self.subst('$'+suffix)
         prefix = self.subst('$'+prefix)
 
diff --git a/SCons/EnvironmentTests.py b/SCons/EnvironmentTests.py
--- a/SCons/EnvironmentTests.py
+++ b/SCons/EnvironmentTests.py
@@ -3182,7 +3182,8 @@ def test_Dump(self) -> None:
         assert len(env.Dump()) > 200, env.Dump()    # no args version
 
         assert env.Dump('FOO', 'json') == '"foo"'    # JSON key version
-        self.assertEqual(env.Dump('FOOFLAGS', 'json'), '"<<non-serializable: CLVar>>"')
+        expect = """[\n    "--bar",\n    "--baz"\n]"""
+        self.assertEqual(env.Dump('FOOFLAGS', 'json'), expect)
         import json
         env_dict = json.loads(env.Dump(format = 'json'))
         assert env_dict['FOO'] == 'foo'    # full JSON version


Test patch: