Repository: omni-us/jsonargparse

Issue:
Support `--print_config skip_default`
Add support for `--print_config skip_default` so only the values different to the default are printed.

Two main benefits:
- Removes the manual work of removing the extra arguments from a config. However, this isn't recommended for reproducibility as it's useful to know exactly what was set.
- Improves backward compatibility. Often features are deprecated in a way that the default argument for a function is re-routed to the new implementation. Since the config saves all old defaults and passes them to the program, it is likely the program will fail when support for the old value is removed.

Comments:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index e697442..b90bc59 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -10,6 +10,15 @@ only be introduced in major versions with advance notice in the **Deprecated**
 section of releases.
 
 
+v4.6.0 (2022-04-??)
+-------------------
+
+Added
+^^^^^
+- Dump option to exclude entries whose value is the same as the default `#91
+  <https://github.com/omni-us/jsonargparse/issues/91>`__.
+
+
 v4.5.0 (2022-03-29)
 -------------------
 
diff --git a/jsonargparse/actions.py b/jsonargparse/actions.py
index 725c357..d6e6753 100644
--- a/jsonargparse/actions.py
+++ b/jsonargparse/actions.py
@@ -244,12 +244,12 @@ class _ActionPrintConfig(Action):
                          dest=dest,
                          default=default,
                          nargs=1,
-                         metavar='[={comments,skip_null}+]',
+                         metavar='[={comments,skip_null,skip_default}+]',
                          help='Print configuration and exit.')
 
     def __call__(self, parser, namespace, value, option_string=None):
         kwargs = {'subparser': parser, 'key': None, 'skip_none': False, 'skip_check': True}
-        valid_flags = {'': None, 'comments': 'yaml_comments', 'skip_null': 'skip_none'}
+        valid_flags = {'': None, 'comments': 'yaml_comments', 'skip_default': 'skip_default', 'skip_null': 'skip_none'}
         if value is not None:
             flags = value[0].split(',')
             invalid_flags = [f for f in flags if f not in valid_flags]
diff --git a/jsonargparse/core.py b/jsonargparse/core.py
index 5e9e97c..46f3a30 100644
--- a/jsonargparse/core.py
+++ b/jsonargparse/core.py
@@ -17,7 +17,7 @@ from .jsonschema import ActionJsonSchema
 from .loaders_dumpers import check_valid_dump_format, dump_using_format, get_loader_exceptions, loaders, load_value, load_value_context, yaml_load
 from .namespace import is_meta_key, Namespace, split_key, split_key_leaf, strip_meta
 from .signatures import is_pure_dataclass, SignatureArguments
-from .typehints import ActionTypeHint
+from .typehints import ActionTypeHint, is_class_object
 from .actions import (
     ActionParser,
     ActionConfigFile,
@@ -658,6 +658,7 @@ class ArgumentParser(_ActionsContainer, argparse.ArgumentParser):
         cfg: Namespace,
         format: str = 'parser_mode',
         skip_none: bool = True,
+        skip_default: bool = False,
         skip_check: bool = False,
         yaml_comments: bool = False,
     ) -> str:
@@ -667,6 +668,7 @@ class ArgumentParser(_ActionsContainer, argparse.ArgumentParser):
             cfg: The configuration object to dump.
             format: The output format: ``'yaml'``, ``'json'``, ``'json_indented'``, ``'parser_mode'`` or ones added via :func:`.set_dumper`.
             skip_none: Whether to exclude entries whose value is None.
+            skip_default: Whether to exclude entries whose value is the same as the default.
             skip_check: Whether to skip parser checking.
             yaml_comments: Whether to add help content as comments. ``yaml_comments=True`` implies ``format='yaml'``.
 
@@ -686,28 +688,57 @@ class ArgumentParser(_ActionsContainer, argparse.ArgumentParser):
             with load_value_context(self.parser_mode):
                 self.check_config(cfg)
 
-        def cleanup_actions(cfg, actions, prefix=''):
-            for action in filter_default_actions(actions):
-                action_dest = prefix + action.dest
-                if (action.help == argparse.SUPPRESS and not isinstance(action, _ActionConfigLoad)) or \
-                   isinstance(action, ActionConfigFile) or \
-                   (skip_none and action_dest in cfg and cfg[action_dest] is None):
-                    cfg.pop(action_dest, None)
-                elif isinstance(action, _ActionSubCommands):
-                    cfg.pop(action_dest, None)
-                    for key, subparser in action.choices.items():
-                        cleanup_actions(cfg, subparser._actions, prefix=prefix+key+'.')
-                elif isinstance(action, ActionTypeHint):
-                    value = cfg.get(action_dest)
-                    if value is not None:
-                        value = action.serialize(value, dump_kwargs={'skip_check': skip_check, 'skip_none': skip_none})
-                        cfg.update(value, action_dest)
-
         with load_value_context(self.parser_mode):
-            cleanup_actions(cfg, self._actions)
+            dump_kwargs = {'skip_check': skip_check, 'skip_none': skip_none}
+            self._dump_cleanup_actions(cfg, self._actions, dump_kwargs)
+
+        cfg = cfg.as_dict()
+
+        if skip_default:
+            self._dump_delete_default_entries(cfg, self.get_defaults().as_dict())
 
         with formatter_context(self):
-            return dump_using_format(self, cfg.as_dict(), 'yaml_comments' if yaml_comments else format)
+            return dump_using_format(self, cfg, 'yaml_comments' if yaml_comments else format)
+
+
+    def _dump_cleanup_actions(self, cfg, actions, dump_kwargs, prefix=''):
+        skip_none = dump_kwargs['skip_none']
+        for action in filter_default_actions(actions):
+            action_dest = prefix + action.dest
+            if (action.help == argparse.SUPPRESS and not isinstance(action, _ActionConfigLoad)) or \
+               isinstance(action, ActionConfigFile) or \
+               (skip_none and action_dest in cfg and cfg[action_dest] is None):
+                cfg.pop(action_dest, None)
+            elif isinstance(action, _ActionSubCommands):
+                cfg.pop(action_dest, None)
+                for key, subparser in action.choices.items():
+                    self._dump_cleanup_actions(cfg, subparser._actions, dump_kwargs, prefix=prefix+key+'.')
+            elif isinstance(action, ActionTypeHint):
+                value = cfg.get(action_dest)
+                if value is not None:
+                    value = action.serialize(value, dump_kwargs=dump_kwargs)
+                    cfg.update(value, action_dest)
+
+
+    def _dump_delete_default_entries(self, subcfg, subdefaults):
+        for key in list(subcfg.keys()):
+            if key in subdefaults:
+                val = subcfg[key]
+                default = subdefaults[key]
+                class_object_val = None
+                if is_class_object(val):
+                    if val['class_path'] != default.get('class_path'):
+                        parser = ActionTypeHint.get_class_parser(val['class_path'])
+                        default = {'init_args': parser.get_defaults().as_dict()}
+                    class_object_val = val
+                    val = val['init_args']
+                    default = default.get('init_args')
+                if val == default:
+                    del subcfg[key]
+                elif isinstance(val, dict) and isinstance(default, dict):
+                    self._dump_delete_default_entries(val, default)
+                    if class_object_val and class_object_val.get('init_args') == {}:
+                        del class_object_val['init_args']
 
 
     def save(
@@ -891,7 +922,7 @@ class ArgumentParser(_ActionsContainer, argparse.ArgumentParser):
         cfg = Namespace()
         for action in filter_default_actions(self._actions):
             if action.default != argparse.SUPPRESS and action.dest != argparse.SUPPRESS:
-                cfg[action.dest] = action.default
+                cfg[action.dest] = deepcopy(action.default)
 
         self._logger.info('Loaded default values from parser.')
 


Test patch:
diff --git a/jsonargparse_tests/core_tests.py b/jsonargparse_tests/core_tests.py
index 00aee2c..263a16b 100755
--- a/jsonargparse_tests/core_tests.py
+++ b/jsonargparse_tests/core_tests.py
@@ -660,6 +660,25 @@ class OutputTests(TempDirTestCase):
         self.assertRaises(ValueError, lambda: parser.dump(cfg, format='invalid'))
 
 
+    def test_dump_skip_default(self):
+        parser = ArgumentParser()
+        parser.add_argument('--op1', default=123)
+        parser.add_argument('--op2', default='abc')
+        self.assertEqual(parser.dump(parser.get_defaults(), skip_default=True), '{}\n')
+        self.assertEqual(parser.dump(Namespace(op1=123, op2='xyz'), skip_default=True), 'op2: xyz\n')
+
+
+    def test_dump_skip_default_nested(self):
+        parser = ArgumentParser()
+        parser.add_argument('--g1.op1', type=int, default=123)
+        parser.add_argument('--g1.op2', type=str, default='abc')
+        parser.add_argument('--g2.op1', type=int, default=987)
+        parser.add_argument('--g2.op2', type=str, default='xyz')
+        self.assertEqual(parser.dump(parser.get_defaults(), skip_default=True), '{}\n')
+        self.assertEqual(parser.dump(parser.parse_args(['--g1.op1=0']), skip_default=True), 'g1:\n  op1: 0\n')
+        self.assertEqual(parser.dump(parser.parse_args(['--g2.op2=pqr']), skip_default=True), 'g2:\n  op2: pqr\n')
+
+
     @unittest.skipIf(not dump_preserve_order_support,
                      'Dump preserve order only supported in python>=3.6 and CPython')
     def test_dump_order(self):
diff --git a/jsonargparse_tests/signatures_tests.py b/jsonargparse_tests/signatures_tests.py
index 956d842..86650d7 100755
--- a/jsonargparse_tests/signatures_tests.py
+++ b/jsonargparse_tests/signatures_tests.py
@@ -1145,36 +1145,37 @@ class SignaturesTests(unittest.TestCase):
 
 
     def test_link_arguments_subclass_missing_param_issue_129(self):
-        class ClassA:
-            def __init__(self, a1: int = 1):
-                self.a1 = a1
+        with suppress_stderr():
+            class ClassA:
+                def __init__(self, a1: int = 1):
+                    self.a1 = a1
 
-        class ClassB:
-            def __init__(self, b1: int = 2):
-                self.b1 = b1
+            class ClassB:
+                def __init__(self, b1: int = 2):
+                    self.b1 = b1
 
-        with mock_module(ClassA, ClassB) as module, self.assertLogs(level='DEBUG') as log, suppress_stderr():
             parser = ArgumentParser(error_handler=None, logger={'level': 'DEBUG'})
-            parser.add_subclass_arguments(ClassA, 'a', default=lazy_instance(ClassA))
-            parser.add_subclass_arguments(ClassB, 'b', default=lazy_instance(ClassB))
-            parser.link_arguments('a.init_args.a2', 'b.init_args.b1', apply_on='parse')
-            parser.link_arguments('a.init_args.a1', 'b.init_args.b2', apply_on='parse')
+            with mock_module(ClassA, ClassB) as module, self.assertLogs(level='DEBUG') as log:
+                parser.add_subclass_arguments(ClassA, 'a', default=lazy_instance(ClassA))
+                parser.add_subclass_arguments(ClassB, 'b', default=lazy_instance(ClassB))
+                parser.link_arguments('a.init_args.a2', 'b.init_args.b1', apply_on='parse')
+                parser.link_arguments('a.init_args.a1', 'b.init_args.b2', apply_on='parse')
 
-            cfg = parser.parse_args([f'--a={module}.ClassA', f'--b={module}.ClassB'])
-            self.assertTrue(any('a.init_args.a2 --> b.init_args.b1 ignored since source' in x for x in log.output))
-            self.assertTrue(any('a.init_args.a1 --> b.init_args.b2 ignored since target' in x for x in log.output))
+                parser.parse_args([f'--a={module}.ClassA', f'--b={module}.ClassB'])
+                self.assertTrue(any('a.init_args.a2 --> b.init_args.b1 ignored since source' in x for x in log.output))
+                self.assertTrue(any('a.init_args.a1 --> b.init_args.b2 ignored since target' in x for x in log.output))
 
-        with mock_module(ClassA, ClassB) as module, self.assertLogs(level='DEBUG') as log, suppress_stderr():
             parser = ArgumentParser(error_handler=None, logger={'level': 'DEBUG'})
-            parser.add_subclass_arguments(ClassA, 'a', default=lazy_instance(ClassA))
-            parser.add_subclass_arguments(ClassB, 'b', default=lazy_instance(ClassB))
-            parser.link_arguments('a.init_args.a2', 'b.init_args.b1', apply_on='instantiate')
-            parser.link_arguments('a.init_args.a1', 'b.init_args.b2', apply_on='instantiate')
-
-            cfg = parser.parse_args([f'--a={module}.ClassA', f'--b={module}.ClassB'])
-            cfg_init = parser.instantiate_classes(cfg)
-            self.assertTrue(any('a.init_args.a2 --> b.init_args.b1 ignored since source' in x for x in log.output))
-            self.assertTrue(any('a.init_args.a1 --> b.init_args.b2 ignored since target' in x for x in log.output))
+            with mock_module(ClassA, ClassB) as module, self.assertLogs(level='DEBUG') as log:
+                parser.add_subclass_arguments(ClassA, 'a', default=lazy_instance(ClassA))
+                parser.add_subclass_arguments(ClassB, 'b', default=lazy_instance(ClassB))
+                parser.link_arguments('a.init_args.a2', 'b.init_args.b1', apply_on='instantiate')
+                parser.link_arguments('a.init_args.a1', 'b.init_args.b2', apply_on='instantiate')
+
+                cfg = parser.parse_args([f'--a={module}.ClassA', f'--b={module}.ClassB'])
+                parser.instantiate_classes(cfg)
+                self.assertTrue(any('a.init_args.a2 --> b.init_args.b1 ignored since source' in x for x in log.output))
+                self.assertTrue(any('a.init_args.a1 --> b.init_args.b2 ignored since target' in x for x in log.output))
 
 
     def test_class_from_function(self):
diff --git a/jsonargparse_tests/typehints_tests.py b/jsonargparse_tests/typehints_tests.py
index 38a3f33..dbbd31c 100755
--- a/jsonargparse_tests/typehints_tests.py
+++ b/jsonargparse_tests/typehints_tests.py
@@ -503,6 +503,31 @@ class TypeHintsTests(unittest.TestCase):
         self.assertRaises(ValueError, lambda: lazy_instance(MyClass, param='bad'))
 
 
+    def test_dump_skip_default(self):
+        class MyCalendar(Calendar):
+            def __init__(self, *args, param: str = '0', **kwargs):
+                super().__init__(*args, **kwargs)
+
+        with mock_module(MyCalendar) as module:
+            parser = ArgumentParser()
+            parser.add_argument('--g1.op1', default=123)
+            parser.add_argument('--g1.op2', default='abc')
+            parser.add_argument('--g2.op1', default=4.5)
+            parser.add_argument('--g2.op2', type=Calendar, default=lazy_instance(Calendar, firstweekday=2))
+
+            cfg = parser.get_defaults()
+            dump = parser.dump(cfg, skip_default=True)
+            self.assertEqual(dump, '{}\n')
+
+            cfg.g2.op2.class_path = f'{module}.MyCalendar'
+            dump = parser.dump(cfg, skip_default=True)
+            self.assertEqual(dump, 'g2:\n  op2:\n    class_path: jsonargparse_tests.MyCalendar\n    init_args:\n      firstweekday: 2\n')
+
+            cfg.g2.op2.init_args.firstweekday = 0
+            dump = parser.dump(cfg, skip_default=True)
+            self.assertEqual(dump, 'g2:\n  op2:\n    class_path: jsonargparse_tests.MyCalendar\n')
+
+
 class TypeHintsTmpdirTests(TempDirTestCase):
 
     def test_path(self):