Repository: omni-us/jsonargparse

Issue:
Discard `init_args` after `class_path` change causes error
## 🐛 Bug report

When composing configs, it appears that overwritten class paths do not properly drop the old `init_args` namespace, causing an error when the new class does not have the same `init_args`.

Here is the error message:

```bash
$ main fit --config config_1.yaml --config configs_2.yaml
/unix/atlastracking/svanstroud/miniconda3/envs/salt/lib/python3.10/site-packages/jsonargparse/typehints.py:909: UserWarning: Due to class_path change from 'models.GATv2Attention' to 'models.ScaledDotProductAttention', discarding init_args: {'num_heads': 8, 'head_dim': 16, 'activation': Namespace(class_path='torch.nn.SiLU')}.
  warnings.warn(
usage: main [-h] [-c CONFIG] [--print_config[=flags]] {fit,validate,test,predict,tune} ...
main: error: Parser key "model": Problem with given class_path 'models.Tagger':
    - Parser key "gnn": Does not validate against any of the Union subtypes
      Subtypes: (<class 'torch.nn.modules.module.Module'>, <class 'NoneType'>)
      Errors:
        - Problem with given class_path 'models.Transformer':
          - Parser key "attention": Problem with given class_path 'models.ScaledDotProductAttention':
              - 'Configuration check failed :: No action for destination key "num_heads" to check its value.'
        - Expected a <class 'NoneType'>
      Given value type: <class 'jsonargparse.namespace.Namespace'>
      Given value: Namespace(class_path='models.Transformer', init_args=Namespace(embd_dim=128, num_heads=8, num_layers=8, attention=Namespace(class_path='models.ScaledDotProductAttention', init_args=Namespace(num_heads=8, head_dim=16, activation=Namespace(class_path='torch.nn.SiLU'))), activation='SiLU', residual=True, norm_layer='LayerNorm', dropout=0.1, out_proj=False))

```
### To reproduce

<!--
Please include a code snippet that reproduces the bug and the output that
running it gives. The following snippet templates might help:

1. Using the CLI function

```python
import jsonargparse

# Here define one or more functions or classes
def func1(param1: int, ...):
    ...

# Run the CLI providing the components
jsonargparse.CLI([func1, ...], error_handler=None)
```

2. Manually constructing a parser

```python
import jsonargparse

parser = jsonargparse.ArgumentParser(error_handler=None)
# Here add to the parser only argument(s) relevant to the problem

# If a yaml config is required, it can be included in the same snippet as follows:
import yaml
parser.add_argument('--config', action=jsonargparse.ActionConfigFile)
config = yaml.safe_dump({
    'key1': 'val1',
})

# Preferable that the command line arguments are given to the parse_args call
result = parser.parse_args([f'--config={config}', '--key2=val2', ...])

# If the problem is in the parsed result, print it to stdout
print(parser.dump(result))

# If the problem is in class instantiation
parser.instantiate_classes(result)
```
-->

### Expected behavior

The overwriting class should not be required to have the same `init_args` as the overwritten class. 
It would also be nice to have an option to suppress the warning about overwritten args.

### Environment

<!-- Fill in the list below. -->

- jsonargparse 4.18.0
- Python 3.10.6


Comments:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 23e37a4..5692d1a 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -28,6 +28,8 @@ Fixed
 - List type with empty list default causes failure `PyLaia#48
   <https://github.com/jpuigcerver/PyLaia/issues/48>`__.
 - Pure dataclass instance default being considered as a subclass type.
+- Discard ``init_args`` after ``class_path`` change causes error `#205
+  <https://github.com/omni-us/jsonargparse/issues/205>`__.
 
 
 v4.18.0 (2022-11-29)
diff --git a/jsonargparse/namespace.py b/jsonargparse/namespace.py
index cc62bc7..affd615 100644
--- a/jsonargparse/namespace.py
+++ b/jsonargparse/namespace.py
@@ -244,7 +244,7 @@ class Namespace(argparse.Namespace):
             if isinstance(val, Namespace):
                 if branches:
                     yield key, val
-                for subkey, subval in val.items():
+                for subkey, subval in val.items(branches):
                     yield key+'.'+del_clash_mark(subkey), subval
             else:
                 yield key, val
diff --git a/jsonargparse/typehints.py b/jsonargparse/typehints.py
index 064c6d1..1edc7c8 100644
--- a/jsonargparse/typehints.py
+++ b/jsonargparse/typehints.py
@@ -295,8 +295,12 @@ class ActionTypeHint(Action):
 
     @staticmethod
     def discard_init_args_on_class_path_change(parser_or_action, prev_cfg, cfg):
+        if isinstance(prev_cfg, dict):
+            return
         keys = list(prev_cfg.keys(branches=True))
-        for key in keys:
+        num = 0
+        while num < len(keys):
+            key = keys[num]
             prev_val = prev_cfg.get(key)
             val = cfg.get(key)
             if is_subclass_spec(prev_val) and is_subclass_spec(val):
@@ -305,6 +309,14 @@ class ActionTypeHint(Action):
                     action = _find_action(parser_or_action, key)
                 if isinstance(action, ActionTypeHint):
                     discard_init_args_on_class_path_change(action, prev_val, val)
+                    prev_sub_cfg = prev_val.get('init_args')
+                    if prev_sub_cfg:
+                        sub_add_kwargs = getattr(action, 'sub_add_kwargs', {})
+                        subparser = ActionTypeHint.get_class_parser(val['class_path'], sub_add_kwargs)
+                        sub_cfg = val.get('init_args', Namespace())
+                        ActionTypeHint.discard_init_args_on_class_path_change(subparser, prev_sub_cfg, sub_cfg)
+                    keys = keys[:num+1] + [k for k in keys[num+1:] if not k.startswith(key+'.')]
+            num += 1
 
 
     @staticmethod


Test patch:
diff --git a/jsonargparse_tests/test_link_arguments.py b/jsonargparse_tests/test_link_arguments.py
index ca1d901..9a71eee 100755
--- a/jsonargparse_tests/test_link_arguments.py
+++ b/jsonargparse_tests/test_link_arguments.py
@@ -230,6 +230,9 @@ class LinkArgumentsTests(unittest.TestCase):
             cfg = parser.parse_args(['--a='+json.dumps(a_value), '--c='+json.dumps(c_value)])
             self.assertEqual(cfg.a.init_args.a1, c_value)
             self.assertEqual(cfg.a.init_args.a2, c_value)
+            init = parser.instantiate_classes(cfg)
+            self.assertIsInstance(init.a, ClassA)
+            self.assertIsInstance(init.c, Calendar)
 
 
     def test_link_arguments_on_parse_within_subcommand(self):
diff --git a/jsonargparse_tests/test_signatures.py b/jsonargparse_tests/test_signatures.py
index 49b7f71..c0364ab 100755
--- a/jsonargparse_tests/test_signatures.py
+++ b/jsonargparse_tests/test_signatures.py
@@ -9,6 +9,7 @@ from calendar import Calendar, January  # type: ignore
 from contextlib import redirect_stderr, redirect_stdout
 from enum import Enum
 from io import StringIO
+from pathlib import Path
 from typing import Any, Dict, List, Optional, Tuple, Union
 
 import yaml
@@ -1309,6 +1310,59 @@ class SignaturesConfigTests(TempDirTestCase):
         self.assertEqual(cfg.fit.model.foo, 123)
 
 
+    def test_config_nested_discard_init_args(self):
+        class Base:
+            def __init__(self, b: float = 0.5):
+                pass
+
+        class Sub1(Base):
+            def __init__(self, s1: str = 'x', **kwargs):
+                super().__init__(**kwargs)
+
+        class Sub2(Base):
+            def __init__(self, s2: int = 3, **kwargs):
+                super().__init__(**kwargs)
+
+        class Main:
+            def __init__(self, sub: Base = lazy_instance(Sub1)) -> None:
+                self.sub = sub
+
+        with mock_module(Base, Sub1, Sub2, Main) as module:
+            subconfig = {
+                'sub': {
+                    'class_path': f'{module}.Sub2',
+                    'init_args': {'s2': 4},
+                }
+            }
+
+            for subtest in ['class', 'subclass']:
+                with self.subTest(subtest), warnings.catch_warnings(record=True) as w:
+                    parser = ArgumentParser(error_handler=None)
+                    parser.add_argument('--config', action=ActionConfigFile)
+
+                    if subtest == 'class':
+                        config = {'main': subconfig}
+                        parser.add_class_arguments(Main, 'main')
+                    else:
+                        config = {
+                            'main': {
+                                'class_path': f'{module}.Main',
+                                'init_args': subconfig,
+                            }
+                        }
+                        parser.add_subclass_arguments(Main, 'main')
+                        parser.set_defaults(main=lazy_instance(Main))
+
+                    config_path = Path('config.yaml')
+                    config_path.write_text(yaml.safe_dump(config))
+
+                    cfg = parser.parse_args([f'--config={config_path}'])
+                    init = parser.instantiate_classes(cfg)
+                    self.assertIsInstance(init.main, Main)
+                    self.assertIsInstance(init.main.sub, Sub2)
+                    self.assertIn("discarding init_args: {'s1': 'x'}", str(w[0].message))
+
+
 @dataclasses.dataclass(frozen=True)
 class MyDataClassA:
     """MyDataClassA description