Repository: omni-us/jsonargparse (python)

Issue:
`jsonargparse` is trying to deepcopy object in some case
<!-- If you like this project, please ⭐ star it https://github.com/omni-us/jsonargparse/ -->

## 🐛 Bug report

I really don't know what to write here, but I have attached code snippet with the errors.

### To reproduce

This fails:
```py
from pytorch_lightning.utilities.cli import LightningCLI
from pytorch_lightning import LightningModule
from torchvision.models.detection.retinanet import retinanet_resnet50_fpn


class RetinaNet(LightningModule):
    def __init__(self, **kwargs):
        super().__init__()
        if False:
            self.model = retinanet_resnet50_fpn(pretrained=pretrained, **kwargs)
        else:
            self.model = retinanet_resnet50_fpn(weights=None, weights_backbone="DEFAULT", **kwargs)

def cli_main():
    LightningCLI(RetinaNet, seed_everything_default=42)

if __name__ == '__main__':
    cli_main()
```

This does not:
```py
from pytorch_lightning.utilities.cli import LightningCLI
from pytorch_lightning import LightningModule
from torchvision.models.detection.retinanet import retinanet_resnet50_fpn


class RetinaNet(LightningModule):
    def __init__(self, **kwargs):
        super().__init__()
        if False:
            self.model = retinanet_resnet50_fpn(pretrained=pretrained)
        else:
            self.model = retinanet_resnet50_fpn(weights=None, weights_backbone="DEFAULT")

def cli_main():
    LightningCLI(RetinaNet, seed_everything_default=42)

if __name__ == '__main__':
    cli_main()
```

This does not:
```py
from pytorch_lightning.utilities.cli import LightningCLI
from pytorch_lightning import LightningModule
from torchvision.models.detection.retinanet import retinanet_resnet50_fpn


class RetinaNet(LightningModule):
    def __init__(self, **kwargs):
        super().__init__()
        self.model = retinanet_resnet50_fpn(weights=None, weights_backbone="DEFAULT", **kwargs)

def cli_main():
    LightningCLI(RetinaNet, seed_everything_default=42)

if __name__ == '__main__':
    cli_main()
```

error:
```console
File "/home/rohit/work/repos/lightning-bolts/build/temp.py", line 20, in cli_main
    LightningCLI(RetinaNet, seed_everything_default=42)
  File "/home/rohit/miniconda3/envs/lit_bolts/lib/python3.9/site-packages/pytorch_lightning/utilities/cli.py", line 157, in __init__
    super().__init__(*args, **kwargs)
  File "/home/rohit/miniconda3/envs/lit_bolts/lib/python3.9/site-packages/pytorch_lightning/cli.py", line 344, in __init__
    self.parse_arguments(self.parser, args)
  File "/home/rohit/miniconda3/envs/lit_bolts/lib/python3.9/site-packages/pytorch_lightning/cli.py", line 516, in parse_arguments
    self.config = parser.parse_args(args)
  File "/home/rohit/miniconda3/envs/lit_bolts/lib/python3.9/site-packages/jsonargparse/deprecated.py", line 112, in patched_parse
    cfg = parse_method(*args, _skip_check=_skip_check, **kwargs)
  File "/home/rohit/miniconda3/envs/lit_bolts/lib/python3.9/site-packages/jsonargparse/core.py", line 382, in parse_args
    parsed_cfg = self._parse_common(
  File "/home/rohit/miniconda3/envs/lit_bolts/lib/python3.9/site-packages/jsonargparse/core.py", line 299, in _parse_common
    _ActionSubCommands.handle_subcommands(self, cfg, env=env, defaults=defaults, fail_no_subcommand=fail_no_subcommand)
  File "/home/rohit/miniconda3/envs/lit_bolts/lib/python3.9/site-packages/jsonargparse/actions.py", line 718, in handle_subcommands
    cfg[key] = subparser.merge_config(cfg.get(key, Namespace()), subnamespace)
  File "/home/rohit/miniconda3/envs/lit_bolts/lib/python3.9/site-packages/jsonargparse/core.py", line 1265, in merge_config
    cfg = cfg_to.clone()
  File "/home/rohit/miniconda3/envs/lit_bolts/lib/python3.9/site-packages/jsonargparse/namespace.py", line 271, in clone
    return deepcopy(self)
  File "/home/rohit/miniconda3/envs/lit_bolts/lib/python3.9/copy.py", line 172, in deepcopy
    y = _reconstruct(x, memo, *rv)
  File "/home/rohit/miniconda3/envs/lit_bolts/lib/python3.9/copy.py", line 270, in _reconstruct
    state = deepcopy(state, memo)
  File "/home/rohit/miniconda3/envs/lit_bolts/lib/python3.9/copy.py", line 146, in deepcopy
    y = copier(x, memo)
  File "/home/rohit/miniconda3/envs/lit_bolts/lib/python3.9/copy.py", line 230, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/home/rohit/miniconda3/envs/lit_bolts/lib/python3.9/copy.py", line 172, in deepcopy
    y = _reconstruct(x, memo, *rv)
  File "/home/rohit/miniconda3/envs/lit_bolts/lib/python3.9/copy.py", line 270, in _reconstruct
    state = deepcopy(state, memo)
  File "/home/rohit/miniconda3/envs/lit_bolts/lib/python3.9/copy.py", line 146, in deepcopy
    y = copier(x, memo)
  File "/home/rohit/miniconda3/envs/lit_bolts/lib/python3.9/copy.py", line 230, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/home/rohit/miniconda3/envs/lit_bolts/lib/python3.9/copy.py", line 172, in deepcopy
    y = _reconstruct(x, memo, *rv)
  File "/home/rohit/miniconda3/envs/lit_bolts/lib/python3.9/copy.py", line 264, in _reconstruct
    y = func(*args)
  File "/home/rohit/miniconda3/envs/lit_bolts/lib/python3.9/enum.py", line 384, in __call__
    return cls.__new__(cls, value)
  File "/home/rohit/miniconda3/envs/lit_bolts/lib/python3.9/enum.py", line 703, in __new__
    raise ve_exc
```

<!--
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

<!-- Describe how the behavior or output of the reproduction snippet should be. -->

### Environment

<!-- Fill in the list below. -->

- jsonargparse version (e.g., 4.8.0): master
- Python version (e.g., 3.9): 3.9
- How jsonargparse was installed (e.g. `pip install jsonargparse[all]`):
- OS (e.g., Linux): Linux


Pull request description:
AST resolver support for conditionals
## What does this PR do?

Fixes #187

## Before submitting

- [x] Did you read the [contributing guideline](https://github.com/omni-us/jsonargparse/blob/master/CONTRIBUTING.rst)?
- [x] Did you update **the documentation**? (readme and public docstrings)
- [x] Did you write **unit tests** such that there is 100% coverage on related code? (required for bug fixes and new features)
- [x] Did you verify that new and existing **tests pass locally**?
- [x] Did you make sure that all changes preserve **backward compatibility**?
- [x] Did you update **the CHANGELOG**? (not for typos, docs, test updates, or minor internal changes/refactors)


Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 9978d96..fdb1696 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -12,13 +12,22 @@ The semantic versioning only considers the public API as described in
 paths are considered internals and can change in minor and patch releases.
 
 
-v4.16.1 (2022-11-??)
+v4.17.0 (2022-11-??)
 --------------------
 
+Added
+^^^^^
+- AST resolver now ignores if/elif/else code when condition is a global constant
+  `#187 <https://github.com/omni-us/jsonargparse/issues/187>`__.
+- AST resolver support for conditional ``**kwargs`` use in multiple calls `#187
+  <https://github.com/omni-us/jsonargparse/issues/187#issuecomment-1295141338>`__.
+
 Fixed
 ^^^^^
 - ``str`` type fails to parse value when pyyaml raises ``ConstructorError``
   `#189 <https://github.com/omni-us/jsonargparse/issues/189>`__.
+- ``Namespace`` clone should not deepcopy leaf values `#187
+  <https://github.com/omni-us/jsonargparse/issues/187>`__.
 
 
 v4.16.0 (2022-10-28)
diff --git a/README.rst b/README.rst
index e44e098..575662d 100644
--- a/README.rst
+++ b/README.rst
@@ -155,11 +155,6 @@ tool. In a shell you could see the help and run a command as follows:
     $ python example.py Lucky --prize=1000
     Lucky won 1000€!
 
-.. doctest:: :hide:
-
-    >>> CLI(command, args=['Lucky', '--prize=1000'])
-    Lucky won 1000€!
-
 .. note::
 
     Parsing of docstrings is an optional feature. For this example to work as
@@ -1440,6 +1435,14 @@ unrelated to these variables.
     def gets_from_kwargs(**kwargs):
         val = kwargs.get('name', 'default')
 
+    def constant_conditional(**kwargs):
+        if global_boolean_1:
+            first_function(**kwargs)
+        elif not global_boolean_2:
+            second_function(**kwargs)
+        else:
+            third_function(**kwargs)
+
 **Cases for classes**
 
 .. testcode:: ast_resolver
@@ -1498,6 +1501,29 @@ class, there can be additional parameters. For example in:
 The ``param`` parameter would be excluded from the resolved parameters because
 it is internally hard coded.
 
+A special case which is supported but with caveats, is multiple calls that use
+``**kwargs``. For example:
+
+.. testcode:: ast_resolver
+
+    def conditional_calls(**kwargs):
+        if condition_1:
+            first_function(**kwargs)
+        elif condition_2:
+            second_function(**kwargs)
+        else:
+            third_function(**kwargs)
+
+The resolved parameters that have the same type hint and default accross all
+calls are supported normally. When there is a discrepancy between the calls, the
+parameters behave differently and are shown in the help in special "Conditional
+arguments" sections. The main difference is that these arguments are not
+included in :py:meth:`.ArgumentParser.get_defaults` or the output of
+``--print_config``. This is necessary because the parser does not know which of
+the calls will be used at runtime, and adding them would cause
+:py:meth:`.ArgumentParser.instantiate_classes` to fail due to unexpected keyword
+arguments.
+
 .. note::
 
     The parameter resolvers log messages of failures and unsupported cases. To
diff --git a/jsonargparse/core.py b/jsonargparse/core.py
index e9e49a6..4b41376 100644
--- a/jsonargparse/core.py
+++ b/jsonargparse/core.py
@@ -7,7 +7,6 @@ import logging
 import os
 import re
 import sys
-from copy import deepcopy
 from typing import Any, Callable, Dict, List, NoReturn, Optional, Sequence, Set, Tuple, Type, Union
 
 from .formatters import DefaultHelpFormatter, empty_help, formatter_context, get_env_var
@@ -24,7 +23,8 @@ from .loaders_dumpers import (
     set_omegaconf_loader,
     yaml_load,
 )
-from .namespace import is_meta_key, Namespace, patch_namespace, split_key, split_key_leaf, strip_meta
+from .namespace import is_meta_key, Namespace, patch_namespace, recreate_branches, split_key, split_key_leaf, strip_meta
+from .parameter_resolvers import ConditionalDefault
 from .signatures import is_pure_dataclass, SignatureArguments
 from .typehints import ActionTypeHint, is_subclass_spec
 from .typing import is_final_class
@@ -699,7 +699,7 @@ class ArgumentParser(ActionsContainer, ArgumentLinking, argparse.ArgumentParser)
         """
         check_valid_dump_format(format)
 
-        cfg = deepcopy(cfg)
+        cfg = cfg.clone()
         cfg = strip_meta(cfg)
         ActionLink.strip_link_target_keys(self, cfg)
 
@@ -817,7 +817,7 @@ class ArgumentParser(ActionsContainer, ArgumentLinking, argparse.ArgumentParser)
                 f.write(self.dump(cfg, **dump_kwargs))  # type: ignore
 
         else:
-            cfg = deepcopy(cfg)
+            cfg = cfg.clone()
             ActionLink.strip_link_target_keys(self, cfg)
 
             if not skip_check:
@@ -944,8 +944,12 @@ class ArgumentParser(ActionsContainer, ArgumentLinking, 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] = deepcopy(action.default)
+            if (
+                action.default != argparse.SUPPRESS and
+                action.dest != argparse.SUPPRESS and
+                not isinstance(action.default, ConditionalDefault)
+            ):
+                cfg[action.dest] = recreate_branches(action.default)
 
         self._logger.info('Loaded default values from parser.')
 
@@ -1136,7 +1140,7 @@ class ArgumentParser(ActionsContainer, ArgumentLinking, argparse.ArgumentParser)
         Returns:
             The stripped configuration object.
         """
-        cfg = deepcopy(cfg)
+        cfg = cfg.clone()
 
         del_keys = []
         for key in cfg.keys():
diff --git a/jsonargparse/namespace.py b/jsonargparse/namespace.py
index 2bc78c2..bc3a858 100644
--- a/jsonargparse/namespace.py
+++ b/jsonargparse/namespace.py
@@ -2,7 +2,6 @@
 
 import argparse
 from contextlib import contextmanager
-from copy import deepcopy
 from typing import Any, Callable, Dict, Iterator, List, Optional, overload, Tuple, Union
 
 
@@ -49,9 +48,9 @@ def strip_meta(cfg):
         cfg: The configuration object to strip.
 
     Returns:
-        A deepcopy of the configuration object excluding all metadata keys.
+        A copy of the configuration object excluding all metadata keys.
     """
-    cfg = deepcopy(cfg)
+    cfg = recreate_branches(cfg)
 
     del_keys = []
     for key in cfg.keys():
@@ -68,6 +67,17 @@ def strip_meta(cfg):
     return cfg
 
 
+def recreate_branches(data):
+    new_data = data
+    if isinstance(data, (Namespace, dict)):
+        new_data = type(data)()
+        for key, val in getattr(data, '__dict__', data).items():
+            new_data[key] = recreate_branches(val)
+    elif isinstance(data, list):
+        new_data = [recreate_branches(v) for v in data]
+    return new_data
+
+
 @contextmanager
 def patch_namespace():
     namespace_class = argparse.Namespace
@@ -79,13 +89,13 @@ def patch_namespace():
 
 
 def namespace_to_dict(namespace: 'Namespace') -> Dict[str, Any]:
-    """Returns a deepcopy of a nested namespace converted into a nested dictionary."""
+    """Returns a copy of a nested namespace converted into a nested dictionary."""
     return namespace.clone().as_dict()
 
 
 def dict_to_namespace(cfg_dict: Union[Dict[str, Any], 'Namespace']) -> 'Namespace':
     """Converts a nested dictionary into a nested namespace."""
-    cfg_dict = deepcopy(cfg_dict)
+    cfg_dict = recreate_branches(cfg_dict)
     def expand_dict(cfg):
         for k, v in cfg.items():
             if isinstance(v, dict) and all(isinstance(k, str) for k in v.keys()):
@@ -268,7 +278,7 @@ class Namespace(argparse.Namespace):
 
     def clone(self) -> 'Namespace':
         """Creates an new identical nested namespace."""
-        return deepcopy(self)
+        return recreate_branches(self)
 
     def update(self, value: Union['Namespace', Any], key: Optional[str] = None, only_unset: bool = False) -> 'Namespace':
         """Sets or replaces all items from the given nested namespace.
diff --git a/jsonargparse/parameter_resolvers.py b/jsonargparse/parameter_resolvers.py
index ba045f5..3c47529 100644
--- a/jsonargparse/parameter_resolvers.py
+++ b/jsonargparse/parameter_resolvers.py
@@ -10,7 +10,15 @@ from dataclasses import dataclass
 from functools import partial
 from typing import Any, Callable, List, Optional, Tuple, Type, Union
 
-from .util import ClassFromFunctionBase, is_subclass, LoggerProperty, parse_logger
+from .util import (
+    ClassFromFunctionBase,
+    get_import_path,
+    is_subclass,
+    iter_to_set_str,
+    LoggerProperty,
+    parse_logger,
+    unique,
+)
 from .optionals import parse_docs
 
 
@@ -22,19 +30,39 @@ class ParamData:
     kind: Optional[inspect._ParameterKind] = None
     doc: Optional[str] = None
     component: Optional[Union[Callable, Type, Tuple]] = None
-    parent: Optional[Type] = None
+    parent: Optional[Union[Type, Tuple]] = None
+    origin: Optional[Union[str, Tuple]] = None
 
 
 ParamList = List[ParamData]
 parameter_attributes = [s[1:] for s in inspect.Parameter.__slots__]  # type: ignore
 kinds = inspect._ParameterKind
 ast_assign_type: Tuple[Type[ast.AST], ...] = (ast.AnnAssign, ast.Assign)
+param_kwargs_get = '**.get()'
+param_kwargs_pop = '**.pop()'
 
 
 class SourceNotAvailable(Exception):
     "Raised when the source code for some component is not available."
 
 
+class ConditionalDefault(list):
+    def __repr__(self):
+        return iter_to_set_str(self, sep=', ')
+
+
+def get_parameter_origins(component, parent) -> str:
+    if isinstance(component, tuple):
+        assert parent is None or len(component) == len(parent)
+        return iter_to_set_str(
+            get_parameter_origins(c, parent[n] if parent else None)
+            for n, c in enumerate(component)
+        )
+    if parent:
+        return f'{get_import_path(parent)}.{component.__name__}'
+    return get_import_path(component)
+
+
 def is_staticmethod(attr) -> bool:
     return isinstance(attr, staticmethod)
 
@@ -75,6 +103,10 @@ def ast_get_assign_targets(node):
     return node.targets if isinstance(node, ast.Assign) else [node.target]
 
 
+def ast_is_not(node) -> bool:
+    return isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not)
+
+
 dict_ast = ast.dump(ast_variable_load('dict'))
 
 
@@ -239,10 +271,11 @@ def get_kwargs_pop_or_get_parameter(node, component, parent, doc_params, logger)
         name=name,
         annotation=inspect._empty,
         default=default,
-        kind=inspect._ParameterKind.KEYWORD_ONLY,
+        kind=kinds.KEYWORD_ONLY,
         doc=doc_params.get(name),
         parent=parent,
         component=component,
+        origin=param_kwargs_get if node.func.attr == 'get' else param_kwargs_pop,
     )
 
 
@@ -266,35 +299,41 @@ def replace_args_and_kwargs(params: ParamList, args: ParamList, kwargs: ParamLis
     return params
 
 
-def common_parameters(params_list: List[ParamList]) -> ParamList:
+def group_parameters(params_list: List[ParamList]) -> ParamList:
     if len(params_list) == 1:
+        for param in params_list[0]:
+            if not isinstance(param.origin, tuple):
+                param.origin = None
         return params_list[0]
-    common = []
+    grouped = []
+    params_count = 0
+    params_skip = set()
     params_dict = defaultdict(lambda: [])
     for params in params_list:
+        if params[0].origin not in {param_kwargs_get, param_kwargs_pop}:
+            params_count += 1
         for param in params:
-            params_dict[param.name].append(param)
+            if param.name not in params_skip and param.kind != kinds.POSITIONAL_ONLY:
+                params_dict[param.name].append(param)
+                if param.origin == param_kwargs_pop:
+                    params_skip.add(param.name)
     for params in params_dict.values():
-        if (
-            len(params) == len(params_list) and
-            len(set(p.annotation for p in params)) == 1 and
-            kinds.POSITIONAL_ONLY not in set(p.kind for p in params)
-        ):
-            common.append(params[0])
-    return common
-
-
-def merge_parameters(source: Union[ParamData, ParamList], target: ParamList) -> ParamList:
-    if not isinstance(source, list):
-        source = [source]
-    source_dict = {p.name: p for p in source}
-    replace_names = set()
-    for p in target:
-        if p.name in source_dict and p.annotation is inspect._empty and source_dict[p.name] is not inspect._empty:
-            replace_names.add(p.name)
-    target = [p for p in target if p.name not in replace_names]
-    target_names = set(p.name for p in target)
-    return target + [s for s in source if s.name not in target_names]
+        gparam = params[0]
+        types = unique(p.annotation for p in params if p.annotation is not inspect._empty)
+        defaults = unique(p.default for p in params if p.default is not inspect._empty)
+        if len(params) >= params_count and len(types) <= 1 and len(defaults) <= 1:
+            gparam.origin = None
+        else:
+            gparam.parent = tuple(p.parent for p in params)
+            gparam.component = tuple(p.component for p in params)
+            gparam.origin = tuple(p.origin for p in params)
+            gparam.default = ConditionalDefault((p.default for p in params) if len(defaults) > 1 else defaults)
+            if len(types) > 1:
+                gparam.annotation = Union[tuple(types)] if types else inspect._empty
+        docs = [p.doc for p in params if p.doc]
+        gparam.doc = docs[0] if docs else None
+        grouped.append(gparam)
+    return grouped
 
 
 def has_dunder_new_method(cls, attr_name):
@@ -431,6 +470,18 @@ class ParametersVisitor(LoggerProperty, ast.NodeVisitor):
                 self.values_found.append((key, node))
         self.generic_visit(node)
 
+    def visit_If(self, node):
+        is_test_not = ast_is_not(node.test)
+        test_node = node.test.operand if is_test_not else node.test
+        component_globals = self.component.__globals__
+        if isinstance(test_node, ast.Name) and test_node.id in component_globals:
+            condition = bool(component_globals[test_node.id])
+            if is_test_not:
+                condition = not condition
+            body = node.body if condition else node.orelse
+            node = ast.If(test=ast.Constant(value=True), body=body, orelse=[])
+        self.generic_visit(node)
+
     def find_values_usage(self, values):
         self.find_values = values
         self.values_found = []
@@ -487,18 +538,17 @@ class ParametersVisitor(LoggerProperty, ast.NodeVisitor):
         if kwargs_name:
             values_to_find[kwargs_name] = kwargs_value = ast_variable_load(kwargs_name)
 
-        args: ParamList = []
-        kwargs: ParamList = []
         values_found = self.find_values_usage(values_to_find)
         if not values_found:
-            return args, kwargs
+            return [], []
 
+        params_list = []
         kwargs_value_dump = ast.dump(kwargs_value)
         for node in [v for k, v in values_found if k == kwargs_name]:
             if isinstance(node, ast.Call):
                 if ast_is_kwargs_pop_or_get(node, kwargs_value_dump):
                     param = get_kwargs_pop_or_get_parameter(node, self.component, self.parent, self.doc_params, self.logger)
-                    kwargs = merge_parameters(param, kwargs)
+                    params_list.append([param])
                     continue
                 kwarg = ast_get_call_kwarg_with_value(node, kwargs_value)
                 params = []
@@ -511,19 +561,22 @@ class ParametersVisitor(LoggerProperty, ast.NodeVisitor):
                     get_param_args = self.get_node_component(node)
                     if get_param_args:
                         params = get_signature_parameters(*get_param_args, logger=self.logger)
-                args, kwargs_ = split_args_and_kwargs(remove_given_parameters(node, params))
-                kwargs = merge_parameters(kwargs_, kwargs)
-                break
+                params = remove_given_parameters(node, params)
+                if params:
+                    self.add_node_origins(params, node)
+                    params_list.append(params)
             elif isinstance(node, ast_assign_type):
                 self_attr = self.parent and ast_is_attr_assign(node, self.self_name)
                 if self_attr:
                     params = self.get_parameters_attr_use_in_members(self_attr)
-                    kwargs = merge_parameters(params, kwargs)
-                    break
+                    if params:
+                        self.add_node_origins(params, node)
+                        params_list.append(params)
                 else:
                     self.logger.debug(f'AST resolver: unsupported type of assign: {ast_str(node)}')
 
-        return args, kwargs
+        params = group_parameters(params_list)
+        return split_args_and_kwargs(params)
 
     def get_parameters_attr_use_in_members(self, attr_name) -> ParamList:
         attr_value = ast_attribute_load(self.self_name, attr_name)
@@ -540,6 +593,14 @@ class ParametersVisitor(LoggerProperty, ast.NodeVisitor):
         self.logger.debug(f'AST resolver: did not find use of {self.self_name}.{attr_name} in members of {self.parent}')
         return []
 
+    def add_node_origins(self, params: ParamList, node) -> None:
+        origin = None
+        for param in params:
+            if param.origin is None:
+                if not origin:
+                    origin = f'{get_parameter_origins(self.component, self.parent)}:{node.lineno}'
+                param.origin = origin
+
     def get_parameters_call_attr(self, attr_name: str, attr_value: ast.AST) -> Optional[ParamList]:
         values_to_find = {attr_name: attr_value}
         values_found = self.find_values_usage(values_to_find)
@@ -547,9 +608,10 @@ class ParametersVisitor(LoggerProperty, ast.NodeVisitor):
         if values_found:
             for _, node in values_found:
                 match = self.match_call_that_uses_attr(node, attr_name)
-                if match is not None:
+                if match:
+                    self.add_node_origins(match, node)
                     matched.append(match)
-            matched = common_parameters(matched)
+            matched = group_parameters(matched)
         return matched or None
 
     def get_parameters(self) -> ParamList:
diff --git a/jsonargparse/signatures.py b/jsonargparse/signatures.py
index bc5514d..1fba318 100644
--- a/jsonargparse/signatures.py
+++ b/jsonargparse/signatures.py
@@ -8,7 +8,7 @@ from typing import Any, Callable, List, Optional, Set, Tuple, Type, Union
 
 from .actions import _ActionConfigLoad
 from .optionals import get_doc_short_description
-from .parameter_resolvers import get_signature_parameters, ParamData
+from .parameter_resolvers import get_parameter_origins, get_signature_parameters, ParamData
 from .typehints import ActionTypeHint, is_optional, LazyInitBaseClass
 from .typing import is_final_class
 from .util import get_import_path, is_subclass, iter_to_set_str, LoggerProperty
@@ -277,8 +277,7 @@ class SignatureArguments(LoggerProperty):
         if default == inspect_empty:
             default = param.default
         is_required = default == inspect_empty
-        src = (param.parent.__name__+'.' if param.parent else '')
-        src += iter_to_set_str(x.__name__ for x in (param.component if isinstance(param.component, tuple) else [param.component]))
+        src = get_parameter_origins(param.component, param.parent)
         skip_message = f'Skipping parameter "{name}" from "{src}" because of: '
         if not fail_untyped and annotation == inspect_empty:
             annotation = Any
@@ -310,6 +309,15 @@ class SignatureArguments(LoggerProperty):
         is_final_class_typehint = is_final_class(annotation)
         dest = (nested_key+'.' if nested_key else '') + name
         args = [dest if is_required and as_positional else '--'+dest]
+        if param.origin:
+            group_name = '; '.join(str(o) for o in param.origin)
+            if group_name in group.parser.groups:
+                group = group.parser.groups[group_name]
+            else:
+                group = group.parser.add_argument_group(
+                    f'Conditional arguments [origins: {group_name}]',
+                    name=group_name,
+                )
         if annotation in {str, int, float, bool} or \
            is_subclass(annotation, (str, int, float)) or \
            is_final_class_typehint or \
diff --git a/jsonargparse/util.py b/jsonargparse/util.py
index 0df0b81..3c2b623 100644
--- a/jsonargparse/util.py
+++ b/jsonargparse/util.py
@@ -14,7 +14,7 @@ from functools import wraps
 from types import BuiltinFunctionType, FunctionType, ModuleType
 from typing import Any, Callable, Optional, Tuple, Type, TypeVar, Union
 
-from .loaders_dumpers import load_value
+from .loaders_dumpers import json_dump, load_value
 from .optionals import (
     fsspec_support,
     get_config_read_mode,
@@ -285,8 +285,30 @@ def change_to_path_dir(path: Optional['Path']):
             os.chdir(cwd)
 
 
+def hash_item(item):
+    try:
+        if isinstance(item, (dict, list)):
+            item_hash = hash(json_dump(item))
+        else:
+            item_hash = hash(item)
+    except Exception:
+        item_hash = hash(repr(item))
+    return item_hash
+
+
+def unique(iterable):
+    unique_items = []
+    seen = set()
+    for item in iterable:
+        key = hash_item(item)
+        if key not in seen:
+            unique_items.append(item)
+            seen.add(key)
+    return unique_items
+
+
 def iter_to_set_str(val, sep=','):
-    val = list(val)
+    val = unique(val)
     if len(val) == 1:
         return str(val[0])
     return '{'+sep.join(str(x) for x in val)+'}'


Test patch:
diff --git a/jsonargparse_tests/test_parameter_resolvers.py b/jsonargparse_tests/test_parameter_resolvers.py
index 3fd83ae..4d6dd88 100755
--- a/jsonargparse_tests/test_parameter_resolvers.py
+++ b/jsonargparse_tests/test_parameter_resolvers.py
@@ -148,19 +148,23 @@ class ClassG:
         elif self.func == '2':
             self.method2(**self.kws)
 
-    def method1(self, kmg1: int, kmg2: str, kmg3: bool):
+    def method1(self, kmg1: int = 1, kmg2: str = '-', kmg3: bool = True):
         """
         Args:
             kmg1: help for kmg1
+            kmg2: help for kmg2
             kmg3: help for kmg3
         """
+        self.called = 'method1'
 
-    def method2(self, kmg1: int, kmg2: float, kmg3: bool, kmg4: int):
+    def method2(self, kmg1: int = 1, kmg2: float = 2.3, kmg3: bool = False, kmg4: int = 4):
         """
         Args:
             kmg1: help for kmg1
             kmg3: help for kmg3
+            kmg4: help for kmg4
         """
+        self.called = 'method2'
 
 class ClassM1:
     def __init__(self, km1: int = 1):
@@ -290,6 +294,7 @@ def function_make_class_b(*args, k1: str = '-', **kwargs):
 def function_pop_get_from_kwargs(kn1: int = 0, **kw):
     """
     Args:
+        k2: help for k2
         kn1: help for kn1
         kn2: help for kn2
         kn3: help for kn3
@@ -299,7 +304,9 @@ def function_pop_get_from_kwargs(kn1: int = 0, **kw):
     kw.pop('kn2', 0.5)
     kw.get('kn3', {})
     kw.pop('kn4', [1])
-    return function_no_args_no_kwargs(**kw)
+    kw.get('pk1', '')
+    function_no_args_no_kwargs(**kw)
+    kw.pop('pk1', '')
 
 def function_with_bug(**kws):
     return does_not_exist(**kws)  # pylint: disable=undefined-variable
@@ -313,16 +320,60 @@ def function_module_class(**kwds):
     return calendar.Calendar(**kwds)
 
 
+constant_boolean_1 = True
+constant_boolean_2 = False
+
+
+def function_constant_boolean(**kwargs):
+    if constant_boolean_1:
+        return function_with_kwargs(**kwargs)
+    elif not constant_boolean_2:
+        return function_with_kwargs(k1=False, **kwargs)
+
+def cond_1(kc: int = 1, kn0: str = 'x', kn1: str = '-'):
+    """
+    Args:
+        kc: help for kc
+        kn1: help for kn1
+    """
+
+def cond_2(kc: int = 1, kn2: bool = True):
+    """
+    Args:
+        kn2: help for kn2
+    """
+
+def cond_3(kc: int = 1, kn3: int = 2, kn4: float = 0.1):
+    """
+    Args:
+        kn3: help for kn3
+        kn4: help for kn4
+    """
+
+def conditional_calls(**kwargs):
+    if 'kn1' in kwargs:
+        cond_1(kn0='y', **kwargs)
+    elif 'kn2' in kwargs:
+        cond_2(**kwargs)
+    else:
+        cond_3(**kwargs)
+
+
 @contextmanager
 def source_unavailable():
     with patch('inspect.getsource', side_effect=OSError('could not get source code')):
         yield
 
 
-def assert_params(self, params, expected):
+def assert_params(self, params, expected, origins={}):
     self.assertEqual(expected, [p.name for p in params])
     docs = [f'help for {p.name}' for p in params] if docstring_parser_support else [None] * len(params)
     self.assertEqual(docs, [p.doc for p in params])
+    param_origins = {
+        n: [o.split(f'{__name__}.', 1)[1] for o in p.origin]
+        for n, p in enumerate(params) if p.origin is not None
+    }
+    self.assertEqual(param_origins, origins)
 
 
 logger = logging.getLogger('test_parameter_resolvers')
@@ -367,7 +418,16 @@ class GetClassParametersTests(unittest.TestCase):
             assert_params(self, get_params(ClassF1), [])
 
     def test_get_params_class_kwargs_in_attr_method_conditioned_on_arg(self):
-        assert_params(self, get_params(ClassG), ['func', 'kmg1', 'kmg3'])
+        assert_params(
+            self,
+            get_params(ClassG),
+            ['func', 'kmg1', 'kmg2', 'kmg3', 'kmg4'],
+            {
+                2: ['ClassG._run:3', 'ClassG._run:5'],
+                3: ['ClassG._run:3', 'ClassG._run:5'],
+                4: ['ClassG._run:5'],
+            },
+        )
         with source_unavailable():
             assert_params(self, get_params(ClassG), ['func'])
 
@@ -455,8 +515,7 @@ class GetFunctionParametersTests(unittest.TestCase):
     def test_get_params_function_pop_get_from_kwargs(self):
         with self.assertLogs(logger, level='DEBUG') as log:
             params = get_params(function_pop_get_from_kwargs, logger=logger)
-            assert_params(self, params, ['kn1', 'kn2', 'kn3', 'kn4', 'pk1', 'k2'])
-            self.assertIs(params[-1].annotation, int)
+            assert_params(self, params, ['kn1', 'k2', 'kn2', 'kn3', 'kn4', 'pk1'])
             self.assertIn('unsupported kwargs pop/get default', log.output[0])
         with source_unavailable():
             assert_params(self, get_params(function_pop_get_from_kwargs), ['kn1'])
@@ -465,6 +524,28 @@ class GetFunctionParametersTests(unittest.TestCase):
         params = get_params(function_module_class)
         self.assertEqual(['firstweekday'], [p.name for p in params])
 
+    def test_get_params_function_constant_boolean(self):
+        assert_params(self, get_params(function_constant_boolean), ['k1', 'pk1', 'k2'])
+        with patch.dict(function_constant_boolean.__globals__, {'constant_boolean_1': False}):
+            assert_params(self, get_params(function_constant_boolean), ['pk1', 'k2'])
+            with patch.dict(function_constant_boolean.__globals__, {'constant_boolean_2': True}):
+                self.assertEqual(get_params(function_constant_boolean), [])
+
+    def test_conditional_calls_kwargs(self):
+        assert_params(
+            self,
+            get_params(conditional_calls),
+            ['kc', 'kn1', 'kn2', 'kn3', 'kn4'],
+            {
+                1: ["conditional_calls:3"],
+                2: ['conditional_calls:5'],
+                3: ['conditional_calls:7'],
+                4: ['conditional_calls:7'],
+            },
+        )
+        with source_unavailable():
+            self.assertEqual(get_params(conditional_calls), [])
+
 
 class OtherTests(unittest.TestCase):
 
diff --git a/jsonargparse_tests/test_signatures.py b/jsonargparse_tests/test_signatures.py
index 779806c..d95107e 100755
--- a/jsonargparse_tests/test_signatures.py
+++ b/jsonargparse_tests/test_signatures.py
@@ -209,6 +209,46 @@ class SignaturesTests(unittest.TestCase):
         self.assertEqual(cfg.a, Namespace(a1=4, a2=2.3))
 
 
+    def test_add_class_conditional_kwargs(self):
+        from jsonargparse_tests.test_parameter_resolvers import ClassG
+
+        parser = ArgumentParser()
+        parser.add_class_arguments(ClassG, 'g')
+
+        cfg = parser.get_defaults()
+        self.assertEqual(cfg.g, Namespace(func=None, kmg1=1))
+
+        cfg = parser.parse_args(['--g.func=1', '--g.kmg2=x'])
+        self.assertEqual(cfg.g, Namespace(func='1', kmg1=1, kmg2='x'))
+        init = parser.instantiate_classes(cfg)
+        init.g._run()
+        self.assertEqual(init.g.called, 'method1')
+
+        cfg = parser.parse_args(['--g.func=2', '--g.kmg4=5'])
+        self.assertEqual(cfg.g, Namespace(func='2', kmg1=1, kmg4=5))
+        init = parser.instantiate_classes(cfg)
+        init.g._run()
+        self.assertEqual(init.g.called, 'method2')
+
+        help_str = StringIO()
+        parser.print_help(help_str)
+        module = 'jsonargparse_tests.test_parameter_resolvers'
+        expected = [
+            f'origins: {module}.ClassG._run:3; {module}.ClassG._run:5',
+            f'origins: {module}.ClassG._run:5',
+        ]
+        if docstring_parser_support:
+            expected += [
+                'help for func (required, type: str)',
+                'help for kmg1 (type: int, default: 1)',
+                'help for kmg2 (type: Union[str, float], default: {-, 2.3})',
+                'help for kmg3 (type: bool, default: {True, False})',
+                'help for kmg4 (type: int, default: 4)',
+            ]
+        for value in expected:
+            self.assertIn(value, help_str.getvalue())
+
+
     def test_add_method_arguments(self):
 
         class MyClass:
@@ -777,8 +817,8 @@ class SignaturesTests(unittest.TestCase):
             with self.assertLogs(logger=parser.logger, level='DEBUG') as log:
                 parser.add_class_arguments(Class2, skip={'c2_a2'})
                 self.assertEqual(1, len(log.output))
-                self.assertIn('"c2_a2" from "Class2.__init__"', log.output[0])
-                self.assertIn('Parameter requested to be skipped', log.output[0])
+                self.assertIn('parameter "c2_a2" from "', log.output[0])
+                self.assertIn('Class2.__init__" because of: Parameter requested to be skipped', log.output[0])
 
 
     def test_instantiate_classes(self):
diff --git a/jsonargparse_tests/test_util.py b/jsonargparse_tests/test_util.py
index ff8a2aa..f6484e4 100755
--- a/jsonargparse_tests/test_util.py
+++ b/jsonargparse_tests/test_util.py
@@ -8,7 +8,7 @@ import unittest
 import zipfile
 from jsonargparse import ArgumentParser, LoggerProperty, null_logger, Path
 from jsonargparse.optionals import fsspec_support, import_fsspec, reconplogger_support, url_support
-from jsonargparse.util import get_import_path, import_object, register_unresolvable_import_paths
+from jsonargparse.util import get_import_path, import_object, register_unresolvable_import_paths, unique
 from jsonargparse_tests.base import is_posix, mock_module, responses_activate, responses_available, suppress_stderr, TempDirTestCase
 
 
@@ -309,5 +309,12 @@ class ImportFunctionsTests(unittest.TestCase):
             self.assertEqual(get_import_path(func), f'{module_name}.func')
 
 
+class OtherTests(unittest.TestCase):
+
+    def test_unique(self):
+        data = [1.0, 2, {}, 'x', ([], {}), 2, [], {}, [], ([], {}), 2]
+        self.assertEqual(unique(data), [1.0, 2, {}, 'x', ([], {}), []])
+
+
 if __name__ == '__main__':
     unittest.main(verbosity=2)