Repository: omni-us/jsonargparse

Issue:
Support passing lists of class_path items via command line
When one wants to pass a list of objects to a parser, this works very well using configs:

```python
from typing import List, Optional

from jsonargparse import ActionConfigFile

from pytorch_lightning.utilities.cli import ArgumentParser


class A:
    def __init__(self, name: str):
        self.name = name

    def __repr__(self):
        return self.name

class B:
    def __init__(self, people: Optional[List[A]] = None):
        self.people = people

    def __repr__(self):
        return repr(list(self.people))


parser = ArgumentParser(parse_as_dict=True)
parser.add_argument("--config", action=ActionConfigFile)
parser.add_class_arguments(B, nested_key="foo")
args = parser.parse_args()
args = parser.instantiate_classes(args)

print(args)
```

```
foo:
  people:
    - class_path: __main__.A
      init_args:
        name: one
    - class_path: __main__.A
      init_args:
        name: two
    - class_path: __main__.A
      init_args:
        name: three
```
(ignore the silly names)

```python
python script.py --config=config.yaml
{'foo': [one, two, three]}
```

However, for users who do not want or cannot use configs, their only option is to write the entire config through command line:

```python
$ python script.py --foo.people='[{class_path: __main__.A, init_args: {name: one}}, {class_path: __main__.A, init_args: {name: two}}, {class_path: __main__.A, init_args: {name: three}}]'
{'foo': [one, two, three]}
```

This is very long to write and error prone.

## Pitch

Support the previous by doing:

```python
python script.py \
    --foo.people.class_path=__main__.A --foo.people.init_args.name=one \
    --foo.people.class_path=__main__.A --foo.people.init_args.name=two \
    --foo.people.class_path=__main__.A --foo.people.init_args.name=three
```

where the order is important, as the `init_args` apply to the previous item with the same nested key.

> Note: after #84 is implemented, this would become:
> ```python
> python script.py \
>    --foo.people=A --foo.people.name=one \
>    --foo.people=A --foo.people.name=two \
>    --foo.people=A --foo.people.name=three
> ```

## Additional context:

We are implementing this in https://github.com/PyTorchLightning/pytorch-lightning/pull/8815

Discussion:
sonarcloud[bot]: SonarCloud Quality Gate failed.&nbsp; &nbsp; [![Quality Gate failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/failed-16px.png 'Quality Gate failed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=138)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=CODE_SMELL) [![B](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/B-16px.png 'B')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=CODE_SMELL) [2 Code Smells](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=138) No Coverage information  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=138&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=138&metric=new_duplicated_lines_density&view=list)


mauvilsa: @carmocca can you please review. Mostly to look at the unit tests in case you observe any important use cases that I forgot to add.
lgtm-com[bot]: This pull request **introduces 1 alert** when merging ab0163a6dc516b36aa75c8a0914930b1713ad586 into 8506d7944478a6f6a2be9840944a86eb454254a9 - [view on LGTM.com](https://lgtm.com/projects/g/omni-us/jsonargparse/rev/pr-f5ed53c8e4dd1678c0950298fe991640606dba4c)

**new alerts:**

* 1 for Unused import
sonarcloud[bot]: SonarCloud Quality Gate failed.&nbsp; &nbsp; [![Quality Gate failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/failed-16px.png 'Quality Gate failed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=138)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=CODE_SMELL) [![B](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/B-16px.png 'B')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=CODE_SMELL) [2 Code Smells](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=138) No Coverage information  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=138&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=138&metric=new_duplicated_lines_density&view=list)


lgtm-com[bot]: This pull request **fixes 1 alert** when merging e0fa7b915ac249c27b9fca96253461e0be47e29b into 8506d7944478a6f6a2be9840944a86eb454254a9 - [view on LGTM.com](https://lgtm.com/projects/g/omni-us/jsonargparse/rev/pr-c109cb2a1a53b2cd2755b8087e6722568feb7a05)

**fixed alerts:**

* 1 for Module is imported with &#39;import&#39; and &#39;import from&#39;
sonarcloud[bot]: SonarCloud Quality Gate failed.&nbsp; &nbsp; [![Quality Gate failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/failed-16px.png 'Quality Gate failed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=138)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=CODE_SMELL) [![B](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/B-16px.png 'B')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=CODE_SMELL) [2 Code Smells](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=138&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=138) No Coverage information  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=138&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=138&metric=new_duplicated_lines_density&view=list)


lgtm-com[bot]: This pull request **fixes 1 alert** when merging 162c1a4df03da463c9b56be330ef19d007220907 into 8506d7944478a6f6a2be9840944a86eb454254a9 - [view on LGTM.com](https://lgtm.com/projects/g/omni-us/jsonargparse/rev/pr-12230d0d382474c6c19e9ee84a6abf0126766870)

**fixed alerts:**

* 1 for Module is imported with &#39;import&#39; and &#39;import from&#39;

Patch:
diff --git a/.bumpversion.cfg b/.bumpversion.cfg
index d8f280e..474a57c 100644
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 4.7.3
+current_version = 4.8.0.dev1
 commit = True
 tag = True
 tag_name = v{new_version}
diff --git a/.sonarcloud.properties b/.sonarcloud.properties
index e108811..00a95ff 100644
--- a/.sonarcloud.properties
+++ b/.sonarcloud.properties
@@ -1,3 +1,3 @@
 sonar.sources=jsonargparse
-sonar.projectVersion=4.7.3
+sonar.projectVersion=4.8.0.dev1
 sonar.python.version=3.6, 3.7, 3.8, 3.9, 3.10
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 3ebedc4..12f6844 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -14,6 +14,15 @@ The semantic versioning only considers the public API as described in
 paths are considered internals and can change in any moment.
 
 
+v4.8.0 (2022-05-??)
+-------------------
+
+Added
+^^^^^
+- Support append to lists both from command line and config file `#85
+  <https://github.com/omni-us/jsonargparse/issues/85>`__.
+
+
 v4.7.3 (2022-05-10)
 -------------------
 
diff --git a/README.rst b/README.rst
index f5cd270..842df68 100644
--- a/README.rst
+++ b/README.rst
@@ -858,6 +858,53 @@ Some notes about this support are:
   not validated.
 
 
+.. _list-append:
+
+List append
+-----------
+
+As detailed before, arguments with ``List`` type are supported. By default when
+specifying an argument value, the previous value is replaced, and this also
+holds for lists. Thus, a parse such as ``parser.parse_args(['--list=[1]',
+'--list=[2, 3]'])`` would result in a final value of ``[2, 3]``. However, in
+some cases it might be desided to append to the list instead of replacing. This
+can be achieved by adding ``+`` as suffix to the argument key, for example:
+
+.. testsetup:: append
+
+    from jsonargparse import ArgumentParser
+    from typing import List
+    parser = ArgumentParser()
+
+.. doctest:: append
+
+    >>> parser.add_argument('--list', type=List[int])  # doctest: +IGNORE_RESULT
+    >>> parser.parse_args(['--list=[1]', '--list+=[2, 3]'])
+    Namespace(list=[1, 2, 3])
+    >>> parser.parse_args(['--list=[4]', '--list+=5'])
+    Namespace(list=[4, 5])
+
+Append is also supported in config files. For instance the following two config
+files would first assign a list and then append to this list:
+
+.. code-block:: yaml
+
+    # config1.yaml
+    list:
+    - 1
+
+.. code-block:: yaml
+
+    # config2.yaml
+    list+:
+    - 2
+    - 3
+
+Appending works for any type for the list elements. List elements with class
+type is also supported and the short notation for ``init_args`` when used (see
+:ref:`sub-classes`), gets applied to the last element of the list.
+
+
 .. _restricted-numbers:
 
 Restricted numbers
diff --git a/jsonargparse/__init__.py b/jsonargparse/__init__.py
index 851afcf..f232e94 100644
--- a/jsonargparse/__init__.py
+++ b/jsonargparse/__init__.py
@@ -61,4 +61,4 @@ __all__ += util.__all__
 __all__ += deprecated.__all__
 
 
-__version__ = '4.7.3'
+__version__ = '4.8.0.dev1'
diff --git a/jsonargparse/core.py b/jsonargparse/core.py
index 04c67a4..0454db2 100644
--- a/jsonargparse/core.py
+++ b/jsonargparse/core.py
@@ -26,7 +26,6 @@ from .actions import (
     _ActionPrintConfig,
     _ActionConfigLoad,
     _ActionLink,
-    _ActionHelpClassPath,
     _is_branch_key,
     _find_action,
     _find_action_and_subcommand,
@@ -99,12 +98,12 @@ class ActionsContainer(SignatureArguments, argparse._ActionsContainer, LoggerPro
                     self.add_dataclass_arguments(theclass, nested_key, **kwargs)
                 return _find_action(parser, nested_key)
             if ActionTypeHint.is_supported_typehint(kwargs['type']):
-                if 'action' in kwargs:
-                    raise ValueError('Type hint as type does not allow providing an action.')
-                if ActionTypeHint.is_subclass_typehint(kwargs['type']):
-                    dest = re.sub('^--', '', args[0])
-                    super().add_argument(f'--{dest}.help', action=_ActionHelpClassPath(baseclass=kwargs['type']))  # type: ignore
-                kwargs['action'] = ActionTypeHint(typehint=kwargs.pop('type'), enable_path=enable_path)
+                args = ActionTypeHint.prepare_add_argument(
+                    args=args,
+                    kwargs=kwargs,
+                    enable_path=enable_path,
+                    container=super(),
+                )
         action = super().add_argument(*args, **kwargs)
         if isinstance(action, ActionConfigFile) and getattr(self, '_print_config', None) is not None:
             self.add_argument(self._print_config, action=_ActionPrintConfig)  # type: ignore
@@ -298,6 +297,8 @@ class ArgumentParser(ActionsContainer, argparse.ArgumentParser):
 
         elif defaults:
             cfg = self.merge_config(cfg, self.get_defaults(skip_check=True))
+            with load_value_context(self.parser_mode):
+                ActionTypeHint.apply_appends(self, cfg)
             with _lenient_check_context():
                 ActionTypeHint.add_sub_defaults(self, cfg)
 
diff --git a/jsonargparse/signatures.py b/jsonargparse/signatures.py
index d045545..48f3ad7 100644
--- a/jsonargparse/signatures.py
+++ b/jsonargparse/signatures.py
@@ -6,7 +6,7 @@ from argparse import SUPPRESS
 from functools import wraps
 from typing import Any, Callable, List, Optional, Set, Tuple, Type, Union
 
-from .actions import _ActionConfigLoad, _ActionHelpClassPath
+from .actions import _ActionConfigLoad
 from .namespace import Namespace
 from .typehints import ActionTypeHint, ClassType, is_optional, LazyInitBaseClass
 from .typing import is_final_class
@@ -340,6 +340,8 @@ class SignatureArguments:
             kwargs['required'] = True
         is_subclass_typehint = False
         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 annotation in {str, int, float, bool} or \
            _issubclass(annotation, (str, int, float)) or \
            is_final_class_typehint or \
@@ -348,32 +350,36 @@ class SignatureArguments:
         elif annotation != inspect_empty:
             try:
                 is_subclass_typehint = ActionTypeHint.is_subclass_typehint(annotation)
-                enable_path = is_subclass_typehint and sub_configs
-                kwargs['action'] = ActionTypeHint(typehint=annotation, enable_path=enable_path)
+                kwargs['type'] = annotation
+                sub_add_kwargs = None
+                if is_subclass_typehint:
+                    prefix = name + '.init_args.'
+                    subclass_skip = {s[len(prefix):] for s in skip if s.startswith(prefix)}
+                    sub_add_kwargs = {'fail_untyped': fail_untyped, 'skip': subclass_skip}
+                args = ActionTypeHint.prepare_add_argument(
+                    args=args,
+                    kwargs=kwargs,
+                    enable_path=is_subclass_typehint and sub_configs,
+                    container=group,
+                    sub_add_kwargs=sub_add_kwargs,
+                )
             except ValueError as ex:
                 self.logger.debug(skip_message+str(ex))  # type: ignore
         if 'type' in kwargs or 'action' in kwargs:
-            dest = (nested_key+'.' if nested_key else '') + name
             if dest in added_args:
                 self.logger.debug(skip_message+'Argument already added.')  # type: ignore
             else:
-                opt_str = dest if is_required and as_positional else '--'+dest
                 sub_add_kwargs = {
                     'fail_untyped': fail_untyped,
                     'sub_configs': sub_configs,
                     'instantiate': instantiate,
                 }
-                if is_subclass_typehint:
-                    help_action = group.add_argument(f'--{dest}.help', action=_ActionHelpClassPath(baseclass=annotation))
-                    prefix = name + '.init_args.'
-                    subclass_skip = {s[len(prefix):] for s in skip if s.startswith(prefix)}
-                    help_action.sub_add_kwargs = {'fail_untyped': fail_untyped, 'skip': subclass_skip}
-                elif is_final_class_typehint:
+                if is_final_class_typehint:
                     kwargs.update(sub_add_kwargs)
-                action = group.add_argument(opt_str, **kwargs)
+                action = group.add_argument(*args, **kwargs)
                 action.sub_add_kwargs = sub_add_kwargs
                 if is_subclass_typehint and len(subclass_skip) > 0:
-                    action.sub_add_kwargs['skip'] = subclass_skip  # type: ignore
+                    action.sub_add_kwargs['skip'] = subclass_skip
                 added_args.append(dest)
         elif is_required and fail_untyped:
             raise ValueError(f'Required parameter without a type for {obj} parameter "{name}".')
diff --git a/jsonargparse/typehints.py b/jsonargparse/typehints.py
index 399f6e4..55bbab9 100644
--- a/jsonargparse/typehints.py
+++ b/jsonargparse/typehints.py
@@ -1,6 +1,5 @@
 """Action to support type hints."""
 
-import copy
 import inspect
 import os
 import re
@@ -32,7 +31,7 @@ from typing import (
     Union,
 )
 
-from .actions import _find_action, _find_parent_action, _is_action_value_list
+from .actions import _ActionHelpClassPath, _find_action, _find_parent_action, _is_action_value_list
 from .loaders_dumpers import get_loader_exceptions, load_value
 from .namespace import is_empty_namespace, Namespace
 from .typing import is_final_class, registered_types
@@ -131,6 +130,7 @@ class ActionTypeHint(Action):
             if 'metavar' not in kwargs:
                 kwargs['metavar'] = typehint_metavar(self._typehint)
             super().__init__(**kwargs)
+            self._supports_append = self.supports_append(self._typehint)
             self.normalize_default()
 
 
@@ -144,6 +144,21 @@ class ActionTypeHint(Action):
             self.default = get_import_path(default)
 
 
+    @staticmethod
+    def prepare_add_argument(args, kwargs, enable_path, container, sub_add_kwargs=None):
+        if 'action' in kwargs:
+            raise ValueError('Providing both type and action allowed.')
+        typehint = kwargs.pop('type')
+        if ActionTypeHint.supports_append(typehint):
+            args = tuple(list(args)+[args[0]+'+'])
+        if ActionTypeHint.is_subclass_typehint(typehint):
+            help_action = container.add_argument(args[0]+'.help', action=_ActionHelpClassPath(baseclass=typehint))
+            if sub_add_kwargs:
+                help_action.sub_add_kwargs = sub_add_kwargs
+        kwargs['action'] = ActionTypeHint(typehint=typehint, enable_path=enable_path)
+        return args
+
+
     @staticmethod
     def is_supported_typehint(typehint, full=False):
         """Whether the given type hint is supported."""
@@ -246,6 +261,28 @@ class ActionTypeHint(Action):
             parser._apply_actions(cfg)
 
 
+    @staticmethod
+    def supports_append(action):
+        typehint = typehint_from_action(action)
+        typehint_origin = get_typehint_origin(typehint)
+        return typehint and (
+            typehint_origin in sequence_origin_types or
+            (
+                typehint_origin == Union and
+                any(get_typehint_origin(x) in sequence_origin_types for x in typehint.__args__)
+            )
+        )
+
+    @staticmethod
+    def apply_appends(parser, cfg):
+        for key in [k for k in cfg.keys() if k.endswith('+')]:
+            action = _find_action(parser, key[:-1])
+            if ActionTypeHint.supports_append(action):
+                val = action._check_type(cfg[key], append=True, cfg=cfg)
+                cfg[key[:-1]] = val
+                cfg.pop(key)
+
+
     def serialize(self, value, dump_kwargs=None):
         sub_add_kwargs = getattr(self, 'sub_add_kwargs', {})
         with dump_kwargs_context(dump_kwargs):
@@ -280,20 +317,27 @@ class ActionTypeHint(Action):
                             cfg_dest['class_path'] = default['class_path']
                         except (KeyError, TypeError):
                             pass
-                    if 'class_path' not in cfg_dest:
+                    if not(
+                        ('class_path' in cfg_dest and not isinstance(cfg_dest, list)) or
+                        (self._supports_append and cfg_dest and isinstance(cfg_dest, list) and 'class_path' in cfg_dest[-1])
+                    ):
                         raise ParserError(f'Found {opt_str} but not yet known to which class_path this corresponds.')
                     if not sub_opt.startswith('init_args.'):
                         sub_opt = 'init_args.' + sub_opt
                     if len(sub_opt.split('.', 2)) == 3:
                         val = NestedArg(key=sub_opt[len('init_args.'):], val=val)
                         sub_opt = 'init_args'
-                    cfg_dest[sub_opt] = val
+                    if isinstance(cfg_dest, list):
+                        cfg_dest[-1][sub_opt] = val
+                    else:
+                        cfg_dest[sub_opt] = val
                     val = cfg_dest
-            val = self._check_type(val, cfg=cfg)
+            append = opt_str == f'--{self.dest}+'
+            val = self._check_type(val, append=append, cfg=cfg)
         args[1].update(val, self.dest)
 
 
-    def _check_type(self, value, cfg=None):
+    def _check_type(self, value, append=False, cfg=None):
         islist = _is_action_value_list(self)
         if not islist:
             value = [value]
@@ -305,23 +349,32 @@ class ActionTypeHint(Action):
                 except get_loader_exceptions():
                     config_path = None
                 path_meta = val.pop('__path__', None) if isinstance(val, dict) else None
-                sub_add_kwargs = getattr(self, 'sub_add_kwargs', {})
-                prev_val = cfg.get(self.dest) if cfg else None
+                kwargs = {
+                    'sub_add_kwargs': getattr(self, 'sub_add_kwargs', {}),
+                    'prev_val': cfg.get(self.dest) if cfg else None,
+                    'append': append,
+                }
                 try:
                     with change_to_path_dir(config_path):
-                        val = adapt_typehints(val, self._typehint, prev_val=prev_val, sub_add_kwargs=sub_add_kwargs)
+                        val = adapt_typehints(val, self._typehint, **kwargs)
                 except ValueError as ex:
                     val_is_int_float_or_none = isinstance(val, (int, float)) or val is None
                     if lenient_check.get():
                         value[num] = orig_val if val_is_int_float_or_none else val
                         continue
                     if val_is_int_float_or_none and config_path is None:
-                        val = adapt_typehints(orig_val, self._typehint, prev_val=prev_val, sub_add_kwargs=sub_add_kwargs)
+                        val = adapt_typehints(orig_val, self._typehint, **kwargs)
                     else:
                         if self._enable_path and config_path is None and isinstance(orig_val, str):
                             msg = f'\n- Expected a config path but "{orig_val}" either not accessible or invalid.\n- '
                             raise type(ex)(msg+str(ex)) from ex
                         raise ex
+
+                if not append and self._supports_append:
+                    prev_val = kwargs.get('prev_val')
+                    if isinstance(prev_val, list) and not_append_diff(prev_val, val) and get_typehint_origin(self._typehint) == Union:
+                        warnings.warn(f'Replacing list value "{prev_val}" with "{val}". To append to a list use "{self.dest}+".')
+
                 if path_meta is not None:
                     val['__path__'] = path_meta
                 if isinstance(val, (Namespace, dict)) and config_path is not None:
@@ -388,15 +441,16 @@ class ActionTypeHint(Action):
             return argcomplete_warn_redraw_prompt(prefix, msg)
 
 
-def adapt_typehints(val, typehint, serialize=False, instantiate_classes=False, prev_val=None, sub_add_kwargs=None):
+def adapt_typehints(val, typehint, serialize=False, instantiate_classes=False, prev_val=None, append=False, sub_add_kwargs=None):
 
     adapt_kwargs = {
         'serialize': serialize,
         'instantiate_classes': instantiate_classes,
         'prev_val': prev_val,
+        'append': append,
         'sub_add_kwargs': sub_add_kwargs or {},
     }
-    subtypehints = getattr(typehint, '__args__', None)
+    subtypehints = get_typehint_subtypes(typehint, append=append)
     typehint_origin = get_typehint_origin(typehint) or typehint
 
     # Any
@@ -490,6 +544,14 @@ def adapt_typehints(val, typehint, serialize=False, instantiate_classes=False, p
 
     # List, Iterable or Sequence
     elif typehint_origin in sequence_origin_types:
+        if append and prev_val is not None:
+            if not isinstance(prev_val, list):
+                try:
+                    prev_val = [adapt_typehints(prev_val, subtypehints[0], **adapt_kwargs)]
+                except Exception:
+                    pass
+            if isinstance(prev_val, list):
+                val = prev_val + (val if isinstance(val, list) else [val])
         if not isinstance(val, list):
             raise ValueError(f'Expected a List but got "{val}"')
         if subtypehints is not None:
@@ -506,7 +568,7 @@ def adapt_typehints(val, typehint, serialize=False, instantiate_classes=False, p
                 val = {cast(k): v for k, v in val.items()}
             for k, v in val.items():
                 if "linked_targets" in adapt_kwargs["sub_add_kwargs"]:
-                    kwargs = copy.deepcopy(adapt_kwargs)
+                    kwargs = deepcopy(adapt_kwargs)
                     sub_add_kwargs = kwargs["sub_add_kwargs"]
                     sub_add_kwargs["linked_targets"] = {t[len(k + "."):] for t in sub_add_kwargs["linked_targets"]
                                                         if t.startswith(k + ".")}
@@ -698,6 +760,20 @@ def adapt_class_type(val_class, init_args, serialize, instantiate_classes, sub_a
     return init_args
 
 
+def not_append_diff(val1, val2):
+    if isinstance(val1, list) and isinstance(val2, list):
+        val1 = [x.get('class_path') if is_class_object(x) else x for x in val1]
+        val2 = [x.get('class_path') if is_class_object(x) else x for x in val2]
+    return val1 != val2
+
+
+def get_typehint_subtypes(typehint, append):
+    subtypes = getattr(typehint, '__args__', None)
+    if append and subtypes:
+        subtypes = sorted(subtypes, key=lambda x: get_typehint_origin(x) not in sequence_origin_types)
+    return subtypes
+
+
 def get_typehint_origin(typehint):
     if not hasattr(typehint, '__origin__') and get_import_path(typehint.__class__) == 'types.UnionType':
         return Union
diff --git a/setup.cfg b/setup.cfg
index 10e9a3c..d8e5562 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -68,7 +68,7 @@ jsonargparse =
 
 [metadata]
 name = jsonargparse
-version = 4.7.3
+version = 4.8.0.dev1
 description = Parsing of command line options, yaml/jsonnet config files and/or environment variables based on argparse.
 long_description_content_type = text/x-rst
 author = Mauricio Villegas


Test patch:
diff --git a/jsonargparse_tests/typehints_tests.py b/jsonargparse_tests/typehints_tests.py
index 818b900..4d65804 100755
--- a/jsonargparse_tests/typehints_tests.py
+++ b/jsonargparse_tests/typehints_tests.py
@@ -8,6 +8,7 @@ import re
 import sys
 import unittest
 import uuid
+import warnings
 import yaml
 from calendar import Calendar
 from contextlib import redirect_stderr, redirect_stdout
@@ -226,6 +227,51 @@ class TypeHintsTests(unittest.TestCase):
         self.assertEqual(parser.dump(cfg), 'complex: (2+3j)\n')
 
 
+    def test_list_append(self):
+        parser = ArgumentParser(error_handler=None)
+        parser.add_argument('--val', type=Union[int, str, List[int]])
+        self.assertEqual(0, parser.parse_args(['--val+=0']).val)
+        self.assertEqual([1, 2, 3], parser.parse_args(['--val+=1', '--val+=2', '--val+=3']).val)
+        self.assertEqual([1, 2, 3], parser.parse_args(['--val=[1,2]', '--val+=3']).val)
+        self.assertEqual(1, parser.parse_args(['--val=a', '--val+=1']).val)
+        with warnings.catch_warnings(record=True) as w:
+            self.assertEqual(3, parser.parse_args(['--val=[1,2]', '--val=3']).val)
+            self.assertIn('Replacing list value "[1, 2]" with "3"', str(w[0].message))
+
+
+    def test_list_append_config(self):
+        parser = ArgumentParser(error_handler=None)
+        parser.add_argument('--cfg', action=ActionConfigFile)
+        parser.add_argument('--val', type=List[int], default=[1, 2])
+        self.assertEqual([3, 4], parser.parse_args(['--cfg', 'val: [3, 4]']).val)
+        self.assertEqual([1, 2, 3], parser.parse_args(['--cfg', 'val+: 3']).val)
+        self.assertEqual([1, 2, 3, 4], parser.parse_args(['--cfg', 'val+: [3, 4]']).val)
+        self.assertRaises(ParserError, lambda: parser.parse_args(['--cfg', 'val+: a']))
+
+
+    def test_list_append_subcommand_subclass(self):
+        class A:
+            def __init__(self, cals: Union[Calendar, List[Calendar]] = None):
+                self.cals = cals
+
+        parser = ArgumentParser(error_handler=None)
+        subparser = ArgumentParser()
+        subparser.add_class_arguments(A, 'a')
+        subcommands = parser.add_subcommands()
+        subcommands.add_subcommand('cmd', subparser)
+        cfg = parser.parse_args([
+            'cmd',
+            '--a.cals+=Calendar',
+            '--a.cals.firstweekday=3',
+            '--a.cals+=TextCalendar',
+            '--a.cals.firstweekday=1',
+        ])
+        self.assertEqual(['calendar.Calendar', 'calendar.TextCalendar'], [x.class_path for x in cfg.cmd.a.cals])
+        self.assertEqual([3, 1], [x.init_args.firstweekday for x in cfg.cmd.a.cals])
+        cfg = parser.parse_args(['cmd', f'--a={json.dumps(cfg.cmd.a.as_dict())}', '--a.cals.firstweekday=4'])
+        self.assertEqual(Namespace(firstweekday=4), cfg.cmd.a.cals[-1].init_args)
+
+
     def test_restricted_number_type(self):
         limit_val = random.randint(100, 10000)
         larger_than = restricted_number_type(f'larger_than_{limit_val}', int, ('>', limit_val))