Repository: lebrice/SimpleParsing

Issue:
Allow parsing multiple nested sub commands
## What I am trying to do:

I am training an encoder-decoder neural network and I have multiple different encoders and decoders.
Each encoder and decoder has a separate dataclass config.

I want to be able to specify from the commandline which encoder to use by specifying its config name. Once I specify that, I also want to be able to adjust the selected config fields. So far I was able to accomplish this subparsers:

I have something like
```
@dataclass
class Model:
    encoder: Union[RNNEncoder, ConvEncoder] = RNNEncoder() 
    decoder: RNNDecoder = RNNDecoder()
```
And I simply parse it just like in the [subparsers example](https://github.com/lebrice/SimpleParsing/blob/9d4e4c89991a0dc3220723a203a8a8ae3ede9cca/examples/subparsers/optional_subparsers.py). I then specify the encoder keyword and then it allows me to specify args specific to the selected architecture.

The problem is, if I want to do the same also for decoder, subparsers are not available anymore and I get 
```
error: cannot have multiple subparser arguments
```

## Ideal state
How can I solve my problem with simple_parsing? My ideal syntax would probably be to use the `Union` type in the dataclasses and call it like so:
```
python script.py --some_unrelated_args \
    encoder rnnencoder --args_related_to_rnnencoder \
    decoder convdecoder --args_related_to_convdecoder
```

## Solutions & workarounds

There are quite many suggestions in [this SO thread](https://stackoverflow.com/questions/10448200/how-to-parse-multiple-nested-sub-commands-using-python-argparse). 
The issue can be solved with multiple subparsers and user-side argv parsing. The following code allows me to call my script like so:

```
python script.py \
    --glob1.xx 7 \
    --glob2.xx 5 \
    encoder convencoder \
      --y 2 \
    decoder convdecoder \
      --n 7
```
And get
```
Namespace(decoder=Decoder(decoder=ConvDecoder(n=7)), encoder=Encoder(encoder=ConvEncoder(y=2)), glob1=Global1(xx=7, yy='hello'), glob2=Global2(xx=5, yy='hello'))
```
The code:

```
import sys
import itertools
from functools import partial

from dataclasses import dataclass

from typing import Union
from simple_parsing import ArgumentParser
from argparse import Namespace


@dataclass
class RNNEncoder:
    x: int = 1


@dataclass
class ConvEncoder:
    y: int = 2


@dataclass
class RNNDecoder:
    m: int = 3


@dataclass
class ConvDecoder:
    n: int = 4


@dataclass
class Encoder:
    encoder: Union[RNNEncoder, ConvEncoder] = RNNEncoder()


@dataclass
class Decoder:
    decoder: Union[RNNDecoder, ConvDecoder] = RNNDecoder()


@dataclass
class Global1:
    xx: int = 5
    yy: str = "hello"


@dataclass
class Global2:
    xx: int = 5
    yy: str = "hello"


parser = ArgumentParser()

parser.add_arguments(Global1, dest="glob1")
parser.add_arguments(Global2, dest="glob2")

sub = parser.add_subparsers()
encoder = sub.add_parser("encoder")
encoder.add_arguments(Encoder, dest="encoder")
decoder = sub.add_parser("decoder")
decoder.add_arguments(Decoder, dest="decoder")


def groupargs(arg, commands, currentarg=[None]):
    if(arg in commands.keys()):currentarg[0]=arg
    return currentarg[0]


rest = 'tmp.py encoder convencoder --y 7 decoder convdecoder --n 6'.split() # or sys.argv

argv = rest # sys.argv
commandlines = [(cmd, list(args)) for cmd,args in itertools.groupby(argv, partial(groupargs, commands=sub.choices))]
commandlines[0][1].pop(0)

namespaces = dict()
for cmd, cmdline in commandlines:
    n, r = parser.parse_known_args(cmdline)
    assert len(r) == 0, f"Provided unknown args {r} for command {cmd}"
    if cmd is None:
        namespaces["global"] = n
    else:
        namespaces[cmd] = getattr(n, cmd)

args = Namespace(
    **vars(namespaces.pop("global")),
    **namespaces,
)
print(args)
```
The result looks correct and leverages simple_parsing argument name resulotions etc, so its quite convenient. 
Some caveats and ugliness:

1. Each Union-like option must be in a separate subparser and needs a separate dataclass
2. Attributes from the Union-like classes are accessed like `args.encoder.encoder`, but it would be nicer to have `args.encoder` and get the final class straight away

What would be better:
1. Allow multiple Union fields in simple-parsing and handle the subparsers internally like described above
2. Directly fill in the user-selected class to the main class to allow `args.glob1.encoder` calls

The configs could look like this:

```
@dataclass
class Global:
    letter: Union[A, B] = A()
    number: Union[One, Two] = One()
    some_other_arg: int = 5

parser = ArgumentParser()
parser.add_arguments(Global, dest="glob")
args = parser.parse_args()
```
and we would get the same output like in the example.

### Other stuff I tried:
I also tried to use `Enum` to store the configs for encoder types and it allows me to select correct config, but I cannot adjust the selcet config params. I tried to use `choice` but it did not accept dataclass as an argument.

Discussion:

Patch:
diff --git a/README.md b/README.md
index 3e1ccb5..017cad5 100644
--- a/README.md
+++ b/README.md
@@ -91,6 +91,11 @@ Options ['options']:
         
     These prefixes can also be set explicitly, or not be used at all. For more info, take a look at the [Prefixing Guide](https://github.com/lebrice/SimpleParsing/tree/master/examples/prefixing/README.md)
 
+- ### [Argument subgroups](https://github.com/lebrice/SimpleParsing/tree/master/examples/subgroups/README.md)
+
+    It's easy to choose between different argument groups of arguments, with the `subgroups`
+    function!
+
 - ### [**Easy serialization**](https://github.com/lebrice/SimpleParsing/tree/master/examples/serialization/README.md):
     
     Easily save/load configs to `json` or `yaml`!. 
diff --git a/examples/README.md b/examples/README.md
index 45e321e..786863c 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -5,6 +5,10 @@
 
 - [ML-related Examples](ML/README.md) 
 
+- **NEW**: [Subgroups Example](subgroups/README.md)
+
+<!-- - **NEW**: [Partials Example](partials/README.md) -->
+
 - [Serialization to `json`/`yaml`](serialization/README.md)
 
 - [Attribute Docstrings Example](docstrings/README.md)
@@ -17,4 +21,4 @@
 
 - [Enums Example](enums/README.md)
 
-- [Subparsers Example](subparsers/README.md)
\ No newline at end of file
+- [Subparsers Example](subparsers/README.md)
diff --git a/examples/subgroups/README.md b/examples/subgroups/README.md
new file mode 100644
index 0000000..5dce87d
--- /dev/null
+++ b/examples/subgroups/README.md
@@ -0,0 +1,153 @@
+# Subgroups
+
+Adding a choice between different subgroups of arguments can be very difficult using Argparse.
+Subparsers are not exactly meant for this, and they introduce many errors
+
+
+This friction is one of the motivating factors for a plethora of argument parsing frameworks
+such as Hydra, Click, and others.
+
+Simple-Parsing makes this easy, using the `subgroups` function!
+
+```python
+from __future__ import annotations
+from dataclasses import dataclass
+from simple_parsing import ArgumentParser, choice, subgroups
+from pathlib import Path
+
+
+@dataclass
+class ModelConfig:
+    ...
+
+
+@dataclass
+class DatasetConfig:
+    ...
+
+
+@dataclass
+class ModelAConfig(ModelConfig):
+    lr: float = 3e-4
+    optimizer: str = "Adam"
+    betas: tuple[float, float] = 0.9, 0.999
+
+
+@dataclass
+class ModelBConfig(ModelConfig):
+    lr: float = 1e-3
+    optimizer: str = "SGD"
+    momentum: float = 1.234
+
+
+@dataclass
+class Dataset1Config(DatasetConfig):
+    data_dir: str | Path = "data/foo"
+    foo: bool = False
+
+
+@dataclass
+class Dataset2Config(DatasetConfig):
+    data_dir: str | Path = "data/bar"
+    bar: float = 1.2
+
+
+@dataclass
+class Config:
+
+    # Which model to use
+    model: ModelConfig = subgroups(
+        {"model_a": ModelAConfig, "model_b": ModelBConfig},
+        default=ModelAConfig(),
+    )
+
+    # Which dataset to use
+    dataset: DatasetConfig = subgroups(
+        {"dataset_1": Dataset1Config, "dataset_2": Dataset2Config}, default=Dataset2Config()
+    )
+
+
+parser = ArgumentParser()
+parser.add_arguments(Config, dest="config")
+args = parser.parse_args()
+
+config: Config = args.config
+
+print(config)
+```
+
+Calling this without arguments uses the defaults:
+
+```console
+$ python examples/subgroups/subgroups_example.py
+Config(model=ModelAConfig(lr=0.0003, optimizer='Adam', betas=(0.9, 0.999)), dataset=Dataset2Config(data_dir='data/bar', bar=1.2))
+```
+
+The --help option is generated for the selected subgroups:
+
+```console
+$ python examples/subgroups/subgroups_example.py --help
+usage: subgroups_example.py [-h] [--model {model_a,model_b}] [--dataset {dataset_1,dataset_2}] [--model.lr float] [--model.optimizer str] [--model.betas float float]
+                            [--dataset.data_dir str|Path] [--dataset.bar float]
+
+options:
+  -h, --help            show this help message and exit
+
+Config ['config']:
+  Config(model: 'ModelConfig' = ModelAConfig(lr=0.0003, optimizer='Adam', betas=(0.9, 0.999)), dataset: 'DatasetConfig' = Dataset2Config(data_dir='data/bar', bar=1.2))
+
+  --model {model_a,model_b}
+                        Which model to use (default: ModelAConfig(lr=0.0003, optimizer='Adam', betas=(0.9, 0.999)))
+  --dataset {dataset_1,dataset_2}
+                        Which dataset to use (default: Dataset2Config(data_dir='data/bar', bar=1.2))
+
+ModelAConfig ['config.model']:
+  ModelAConfig(lr: 'float' = 0.0003, optimizer: 'str' = 'Adam', betas: 'tuple[float, float]' = (0.9, 0.999))
+
+  --model.lr float      (default: 0.0003)
+  --model.optimizer str
+                        (default: Adam)
+  --model.betas float float
+                        (default: (0.9, 0.999))
+
+Dataset2Config ['config.dataset']:
+  Dataset2Config(data_dir: 'str | Path' = 'data/bar', bar: 'float' = 1.2)
+
+  --dataset.data_dir str|Path
+                        (default: data/bar)
+  --dataset.bar float   (default: 1.2)
+```
+
+
+```console
+$ python examples/subgroups/subgroups_example.py --model model_b --help
+usage: subgroups_example.py [-h] [--model {model_a,model_b}] [--dataset {dataset_1,dataset_2}] [--model.lr float] [--model.optimizer str] [--model.momentum float]
+                            [--dataset.data_dir str|Path] [--dataset.bar float]
+
+options:
+  -h, --help            show this help message and exit
+
+Config ['config']:
+  Config(model: 'ModelConfig' = ModelAConfig(lr=0.0003, optimizer='Adam', betas=(0.9, 0.999)), dataset: 'DatasetConfig' = Dataset2Config(data_dir='data/bar', bar=1.2))
+
+  --model {model_a,model_b}
+                        Which model to use (default: ModelAConfig(lr=0.0003, optimizer='Adam', betas=(0.9, 0.999)))
+  --dataset {dataset_1,dataset_2}
+                        Which dataset to use (default: Dataset2Config(data_dir='data/bar', bar=1.2))
+
+ModelBConfig ['config.model']:
+  ModelBConfig(lr: 'float' = 0.001, optimizer: 'str' = 'SGD', momentum: 'float' = 1.234)
+
+  --model.lr float      (default: 0.001)
+  --model.optimizer str
+                        (default: SGD)
+  --model.momentum float
+                        (default: 1.234)
+
+Dataset2Config ['config.dataset']:
+  Dataset2Config(data_dir: 'str | Path' = 'data/bar', bar: 'float' = 1.2)
+
+  --dataset.data_dir str|Path
+                        (default: data/bar)
+  --dataset.bar float   (default: 1.2)
+```
\ No newline at end of file
diff --git a/examples/subgroups/subgroups_example.py b/examples/subgroups/subgroups_example.py
new file mode 100644
index 0000000..d02980b
--- /dev/null
+++ b/examples/subgroups/subgroups_example.py
@@ -0,0 +1,102 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+
+from simple_parsing import ArgumentParser, subgroups
+
+
+@dataclass
+class ModelConfig:
+    ...
+
+
+@dataclass
+class DatasetConfig:
+    ...
+
+
+@dataclass
+class ModelAConfig(ModelConfig):
+    lr: float = 3e-4
+    optimizer: str = "Adam"
+    betas: tuple[float, float] = (0.9, 0.999)
+
+
+@dataclass
+class ModelBConfig(ModelConfig):
+    lr: float = 1e-3
+    optimizer: str = "SGD"
+    momentum: float = 1.234
+
+
+@dataclass
+class Dataset1Config(DatasetConfig):
+    data_dir: str | Path = "data/foo"
+    foo: bool = False
+
+
+@dataclass
+class Dataset2Config(DatasetConfig):
+    data_dir: str | Path = "data/bar"
+    bar: float = 1.2
+
+
+@dataclass
+class Config:
+
+    # Which model to use
+    model: ModelConfig = subgroups(
+        {"model_a": ModelAConfig, "model_b": ModelBConfig},
+        default=ModelAConfig(),
+    )
+
+    # Which dataset to use
+    dataset: DatasetConfig = subgroups(
+        {"dataset_1": Dataset1Config, "dataset_2": Dataset2Config}, default=Dataset2Config()
+    )
+
+
+parser = ArgumentParser()
+parser.add_arguments(Config, dest="config")
+args = parser.parse_args()
+
+config: Config = args.config
+
+print(config)
+expected = """
+Config(model=ModelAConfig(lr=0.0003, optimizer='Adam', betas=(0.9, 0.999)), dataset=Dataset2Config(data_dir='data/bar', bar=1.2))
+"""
+
+parser.print_help()
+expected += """
+usage: subgroups_example.py [-h] [--model {model_a,model_b}] [--dataset {dataset_1,dataset_2}] [--model.lr float] [--model.optimizer str] [--model.betas float float]
+                            [--dataset.data_dir str|Path] [--dataset.bar float]
+
+options:
+  -h, --help            show this help message and exit
+
+Config ['config']:
+  Config(model: 'ModelConfig' = ModelAConfig(lr=0.0003, optimizer='Adam', betas=(0.9, 0.999)), dataset: 'DatasetConfig' = Dataset2Config(data_dir='data/bar', bar=1.2))
+
+  --model {model_a,model_b}
+                        Which model to use (default: ModelAConfig(lr=0.0003, optimizer='Adam', betas=(0.9, 0.999)))
+  --dataset {dataset_1,dataset_2}
+                        Which dataset to use (default: Dataset2Config(data_dir='data/bar', bar=1.2))
+
+ModelAConfig ['config.model']:
+  ModelAConfig(lr: 'float' = 0.0003, optimizer: 'str' = 'Adam', betas: 'tuple[float, float]' = (0.9, 0.999))
+
+  --model.lr float      (default: 0.0003)
+  --model.optimizer str
+                        (default: Adam)
+  --model.betas float float
+                        (default: (0.9, 0.999))
+
+Dataset2Config ['config.dataset']:
+  Dataset2Config(data_dir: 'str | Path' = 'data/bar', bar: 'float' = 1.2)
+
+  --dataset.data_dir str|Path
+                        (default: data/bar)
+  --dataset.bar float   (default: 1.2)
+"""
diff --git a/requirements.txt b/requirements.txt
index afc7fc4..3e4c453 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,3 @@
 typing_inspect;python_version<'3.9'
+typing_extensions
 dataclasses;python_version<'3.7'
\ No newline at end of file
diff --git a/simple_parsing/__init__.py b/simple_parsing/__init__.py
index 67a8393..895c574 100644
--- a/simple_parsing/__init__.py
+++ b/simple_parsing/__init__.py
@@ -12,9 +12,10 @@ from .helpers import (
     flag,
     list_field,
     mutable_field,
+    subgroups,
     subparsers,
 )
-from .parsing import ArgumentParser, ParsingError, DashVariant
+from .parsing import ArgumentParser, DashVariant, ParsingError
 from .utils import InconsistentArgumentError
 
 __all__ = [
@@ -22,8 +23,7 @@ __all__ = [
     "utils",
     "wrappers",
     "ConflictResolution",
-    "DashVariant"
-    "MutableField",
+    "DashVariant" "MutableField",
     "Serializable",
     "SimpleHelpFormatter",
     "choice",
@@ -32,6 +32,7 @@ __all__ = [
     "list_field",
     "mutable_field",
     "subparsers",
+    "subgroups",
     "ArgumentParser",
     "InconsistentArgumentError",
 ]
diff --git a/simple_parsing/annotation_utils/__init__.py b/simple_parsing/annotation_utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/simple_parsing/annotation_utils/get_field_annotations.py b/simple_parsing/annotation_utils/get_field_annotations.py
new file mode 100644
index 0000000..0977d9b
--- /dev/null
+++ b/simple_parsing/annotation_utils/get_field_annotations.py
@@ -0,0 +1,184 @@
+import collections
+import sys
+import typing
+from logging import getLogger as get_logger
+from typing import Any, Dict, get_type_hints
+
+logger = get_logger(__name__)
+
+# NOTE: This dict is used to enable forward compatibility with things such as `tuple[int, str]`,
+# `list[float]`, etc. when using `from __future__ import annotations`.
+forward_refs_to_types = {
+    "tuple": typing.Tuple,
+    "set": typing.Set,
+    "dict": typing.Dict,
+    "list": typing.List,
+    "type": typing.Type,
+}
+import inspect
+import types
+
+
+def _replace_UnionType_with_typing_Union(annotation):
+    from simple_parsing.utils import builtin_types, is_dict, is_list, is_tuple
+
+    if sys.version_info[:2] < (3, 10):
+        # This is only useful for python 3.10+ (where UnionTypes exist).
+        # Therefore just return the annotation as-is.
+        return annotation
+
+    if isinstance(annotation, types.UnionType):  # type: ignore
+        union_args = typing.get_args(annotation)
+        new_union_args = tuple(_replace_UnionType_with_typing_Union(arg) for arg in union_args)
+        return typing.Union[new_union_args]  # type: ignore
+    if is_list(annotation):
+        item_annotation = typing.get_args(annotation)[0]
+        new_item_annotation = _replace_UnionType_with_typing_Union(item_annotation)
+        return typing.List[new_item_annotation]
+    if is_tuple(annotation):
+        item_annotations = typing.get_args(annotation)
+        new_item_annotations = tuple(
+            _replace_UnionType_with_typing_Union(arg) for arg in item_annotations
+        )
+        return typing.Tuple[new_item_annotations]  # type: ignore
+    if is_dict(annotation):
+        annotations = typing.get_args(annotation)
+        if not annotations:
+            return typing.Dict
+        assert len(annotations) == 2
+        key_annotation = annotations[0]
+        value_annotation = annotations[1]
+        new_key_annotation = _replace_UnionType_with_typing_Union(key_annotation)
+        new_value_annotation = _replace_UnionType_with_typing_Union(value_annotation)
+        return typing.Dict[new_key_annotation, new_value_annotation]
+    if annotation in builtin_types:
+        return annotation
+    if inspect.isclass(annotation):
+        return annotation
+    raise NotImplementedError(annotation)
+
+
+#     # return forward_refs_to_types.get(ann, local_ns.get(ann, global_ns.get(ann, getattr(builtins, ann, ann))))
+
+
+def _not_supported(annotation) -> typing.NoReturn:
+    raise NotImplementedError(f"Don't yet support annotations like this: {annotation}")
+
+
+def _get_old_style_annotation(annotation: str) -> str:
+    """Replaces A | B with Union[A,B] in the annotation."""
+    # TODO: Add proper support for things like `list[int | float]`, which isn't currently
+    # working, even without the new-style union.
+    if "|" not in annotation:
+        return annotation
+
+    annotation = annotation.strip()
+    if "[" not in annotation:
+        assert "]" not in annotation
+        return "Union[" + ", ".join(v.strip() for v in annotation.split("|")) + "]"
+
+    before, lsep, rest = annotation.partition("[")
+    middle, rsep, after = rest.rpartition("]")
+    # BUG: Need to handle things like bob[int] | None
+    assert (
+        not after.strip()
+    ), f"can't have text at HERE in <something>[<something>]<HERE>!: {annotation}"
+
+    if "|" in before or "|" in after:
+        _not_supported(annotation)
+    assert "|" in middle
+
+    if "," in middle:
+        parts = [v.strip() for v in middle.split(",")]
+        parts = [_get_old_style_annotation(part) for part in parts]
+        middle = ", ".join(parts)
+
+    new_middle = _get_old_style_annotation(annotation=middle)
+    new_annotation = before + lsep + new_middle + rsep + after
+    return new_annotation
+
+
+def _replace_new_union_syntax_with_old_union_syntax(
+    annotations_dict: Dict[str, str], context: collections.ChainMap
+) -> Dict[str, Any]:
+
+    new_annotations = annotations_dict.copy()
+    for field, annotation_str in annotations_dict.items():
+        updated_annotation = _get_old_style_annotation(annotation_str)
+        new_annotations[field] = updated_annotation
+
+    return new_annotations
+
+
+def get_field_type_from_annotations(some_class: type, field_name: str) -> type:
+    """Get the annotation for the given field, in the 'old-style' format with types from
+    typing.List, typing.Union, etc.
+
+    If the script uses `from __future__ import annotations`, and we are in python<3.9,
+    Then we need to actually first make this forward-compatibility 'patch' so that we
+    don't run into a "`type` object is not subscriptable" error.
+
+    NOTE: If you get errors of this kind from the function below, then you might want to add an
+    entry to the `forward_refs_to_types` dict above.
+    """
+
+    # Pretty hacky: Modify the type annotations of the class (preferably a copy of the class
+    # if possible, to avoid modifying things in-place), and replace  the `a | b`-type
+    # expressions with `Union[a, b]`, so that `get_type_hints` doesn't raise an error.
+    # The type of the field might be a string when using `from __future__ import annotations`.
+
+    # The type of the field might be a string when using `from __future__ import annotations`.
+    # Get the local and global namespaces to pass to the `get_type_hints` function.
+    local_ns: Dict[str, Any] = {"typing": typing, **vars(typing)}
+    local_ns.update(forward_refs_to_types)
+    # Get the globals in the module where the class was defined.
+    global_ns = sys.modules[some_class.__module__].__dict__
+
+    try:
+        annotations_dict = get_type_hints(some_class, localns=local_ns, globalns=global_ns)
+    except TypeError as err:
+        annotations_dict = collections.ChainMap(
+            *[getattr(cls, "__annotations__", {}) for cls in some_class.mro()]
+        )
+
+    if field_name not in annotations_dict:
+        raise ValueError(f"Field {field_name} not found in annotations of class {some_class}")
+
+    field_type = annotations_dict[field_name]
+
+    if sys.version_info[:2] >= (3, 7) and isinstance(field_type, typing.ForwardRef):
+        # Weird bug happens when mixing postponed evaluation of type annotations + forward
+        # references: The ForwardRefs are left as-is, and not evaluated!
+        forward_arg = field_type.__forward_arg__
+        field_type = forward_arg
+
+    if sys.version_info >= (3, 10) and isinstance(field_type, types.UnionType):
+        # In python >= 3.10, int | float is allowed. Therefore, just to be consistent, we want
+        # to convert those into the corresponding typing.Union type.
+        # This is necessary for the rest of the code to work, since it's all based on typing.Union.
+        field_type = _replace_UnionType_with_typing_Union(field_type)
+
+    if isinstance(field_type, str) and "|" in field_type:
+        field_type = _get_old_style_annotation(field_type)
+
+        # Pretty hacky:
+        # In order to use `get_type_hints`, we need to pass it a class. We can't just ask it to
+        # evaluate a single annotation. Therefore, we create a temporary class and set it's
+        # __annotation__ attribute, which is introspected by `get_type_hints`.
+
+    try:
+
+        class Temp_:
+            pass
+
+        Temp_.__annotations__ = {field_name: field_type}
+        annotations_dict = get_type_hints(Temp_, globalns=global_ns, localns=local_ns)
+        field_type = annotations_dict[field_name]
+    except:
+        logger.warning(
+            f"Unable to evaluate forward reference {field_type} for field '{field_name}'.\n"
+            f"Leaving it as-is."
+        )
+        field_type = field_type
+
+    return field_type
diff --git a/simple_parsing/helpers/fields.py b/simple_parsing/helpers/fields.py
index 04c8052..bfdc726 100644
--- a/simple_parsing/helpers/fields.py
+++ b/simple_parsing/helpers/fields.py
@@ -1,15 +1,13 @@
-""" Utility functions that simplify defining field of dataclasses. 
+""" Utility functions that simplify defining field of dataclasses.
 """
-import argparse
 import dataclasses
-import enum
 import functools
 import inspect
-import json
 import warnings
 from collections import OrderedDict
 from dataclasses import _MISSING_TYPE, MISSING
 from enum import Enum
+from logging import getLogger
 from typing import (
     Any,
     Callable,
@@ -25,17 +23,7 @@ from typing import (
     overload,
 )
 
-from simple_parsing.utils import (
-    Dataclass,
-    SimpleValueType,
-    get_type_arguments,
-    is_optional,
-    is_tuple,
-    is_union,
-    str2bool,
-)
-
-from logging import getLogger
+from simple_parsing.utils import Dataclass, str2bool
 
 logger = getLogger(__name__)
 
@@ -343,6 +331,40 @@ def mutable_field(
 
 MutableField = mutable_field
 
+T = TypeVar("T")
+
+
+def subgroups(
+    subgroups: Dict[str, Type[T]], *args, default: Union[T, Type[T], None] = None, **kwargs
+) -> T:
+    """Creates a field that will be a choice between different subgroups of arguments.
+
+    This is different than adding a subparser action. There can only be one subparser action, while
+    there can be arbitrarily many subgroups.
+
+    Parameters
+    ----------
+    subgroups : Dict[str, Type[T]]
+        Dictionary mapping from the subgroup name to the subgroup type.
+    default : Optional[T], optional
+        The default subgroup to use, by default None, in which case a subgroup has to be selected.
+        Can either be the type of subgroup, or an instance of the config class for the subgroup.
+
+    Returns
+    -------
+    a field whose type is the Union of the different possible subgroups.
+    """
+    metadata = kwargs.setdefault("metadata", {})
+    metadata["subgroups"] = subgroups
+    choices = subgroups.keys()
+    kwargs["type"] = str
+    if default and inspect.isclass(default):
+        matching_keys = [k for k, v in subgroups.items() if v is default]
+        if len(matching_keys) != 1:
+            raise ValueError(f"Default subgroup {default} is not in the subgroups dict")
+        default = matching_keys[0]
+    return choice(*choices, *args, default=default, **kwargs)  # type: ignore
+
 
 def subparsers(
     subcommands: Dict[str, Type[Dataclass]], default: Dataclass = MISSING, **kwargs
diff --git a/simple_parsing/parsing.py b/simple_parsing/parsing.py
index 91b2ae8..bb0dee1 100644
--- a/simple_parsing/parsing.py
+++ b/simple_parsing/parsing.py
@@ -3,16 +3,17 @@
 """
 import argparse
 import sys
-from argparse import HelpFormatter, Namespace
+from argparse import SUPPRESS, Action, HelpFormatter, Namespace, _, _HelpAction
 from collections import defaultdict
+from functools import partial
 from logging import getLogger
-from typing import Any, Dict, List, Sequence, Text, Type, Union, overload
+from typing import Any, Callable, Dict, List, Sequence, Type, Union, overload
 
 from . import utils
 from .conflicts import ConflictResolution, ConflictResolver
 from .help_formatter import SimpleHelpFormatter
 from .utils import Dataclass
-from .wrappers import DataclassWrapper, FieldWrapper, DashVariant
+from .wrappers import DashVariant, DataclassWrapper, FieldWrapper
 from .wrappers.field_wrapper import ArgumentGenerationMode, NestedMode
 
 logger = getLogger(__name__)
@@ -26,9 +27,11 @@ class ArgumentParser(argparse.ArgumentParser):
     def __init__(
         self,
         *args,
+        parents: Sequence["ArgumentParser"] = (),
+        add_help: bool = True,
         conflict_resolution: ConflictResolution = ConflictResolution.AUTO,
         add_option_string_dash_variants: DashVariant = DashVariant.AUTO,
-        argument_generation_mode = ArgumentGenerationMode.FLAT,
+        argument_generation_mode=ArgumentGenerationMode.FLAT,
         nested_mode: NestedMode = NestedMode.DEFAULT,
         formatter_class: Type[HelpFormatter] = SimpleHelpFormatter,
         **kwargs,
@@ -89,8 +92,9 @@ class ArgumentParser(argparse.ArgumentParser):
             `argparse.RawDescriptionHelpFormatter` classes.
         """
         kwargs["formatter_class"] = formatter_class
-        super().__init__(*args, **kwargs)
-
+        # Pass parents=[] since we override this mechanism below.
+        # NOTE: We end up with the same parents.
+        super().__init__(*args, parents=[], add_help=False, **kwargs)
         self.conflict_resolution = conflict_resolution
         # constructor arguments for the dataclass instances.
         # (a Dict[dest, [attribute, value]])
@@ -100,9 +104,52 @@ class ArgumentParser(argparse.ArgumentParser):
         self._wrappers: List[DataclassWrapper] = []
 
         self._preprocessing_done: bool = False
+        self.add_option_string_dash_variants = add_option_string_dash_variants
+        self.argument_generation_mode = argument_generation_mode
+        self.nested_mode = nested_mode
+
         FieldWrapper.add_dash_variants = add_option_string_dash_variants
         FieldWrapper.argument_generation_mode = argument_generation_mode
         FieldWrapper.nested_mode = nested_mode
+        self._parents = tuple(parents)
+        self._add_argument_replay: List[Callable[["ArgumentParser"], Any]] = []
+
+        self.add_help = add_help
+        if self.add_help:
+            prefix_chars = self.prefix_chars
+            default_prefix = "-" if "-" in prefix_chars else prefix_chars[0]
+            self._help_action = super().add_argument(
+                default_prefix + "h",
+                default_prefix * 2 + "help",
+                action="help",
+                default=SUPPRESS,
+                help=_("show this help message and exit"),
+            )
+
+        # Add parent arguments and defaults.
+        # THis is a little bit different than in Argparse: We replay all the `add_argument` and
+        # `add_arguments` calls, instead of adding the same actions.
+        # NOTE: We could probably also do this in `preprocessing` instead of `__init__`
+        for parent in parents:
+            for add_arguments_call in parent._add_argument_replay:
+                add_arguments_call(self)
+
+    def add_argument(
+        self,
+        *name_or_flags: str,
+        **kwargs,
+    ) -> Action:
+        if hasattr(self, "_add_argument_replay"):
+            # When creating the --help option in super().__init__, we don't yet have this attribute
+            # and we don't want to save this call in the replay list, since it will be called
+            # anyway when creating the child parser based on `add_help`.
+            self._add_argument_replay.append(
+                lambda parser: parser.add_argument(*name_or_flags, **kwargs)
+            )
+        return super().add_argument(
+            *name_or_flags,
+            **kwargs,
+        )
 
     @overload
     def add_arguments(
@@ -153,26 +200,44 @@ class ArgumentParser(argparse.ArgumentParser):
             An instance of the dataclass type to get default values from, by
             default None
         """
+        # Save this call so that we can replay it on any child later.
+        self._add_argument_replay.append(
+            partial(
+                type(self).add_arguments,
+                dataclass=dataclass,
+                dest=dest,
+                prefix=prefix,
+                default=default,
+                dataclass_wrapper_class=dataclass_wrapper_class,
+            )
+        )
         for wrapper in self._wrappers:
             if wrapper.dest == dest:
-                raise argparse.ArgumentError(
-                    argument=None,
-                    message=f"Destination attribute {dest} is already used for "
-                    f"dataclass of type {dataclass}. Make sure all destinations"
-                    f" are unique.",
-                )
+                if (
+                    wrapper.dataclass
+                    == dataclass
+                    # and wrapper.prefix == prefix
+                    # and wrapper.default == default
+                    # and type(wrapper) is dataclass_wrapper_class
+                ):
+                    # pass  # allow overwriting stuff?
+                    # return
+                    raise argparse.ArgumentError(
+                        argument=None,
+                        message=f"Destination attribute {dest} is already used for "
+                        f"dataclass of type {dataclass}. Make sure all destinations"
+                        f" are unique. (new dataclass type: {dataclass})",
+                    )
         if not isinstance(dataclass, type):
             default = dataclass if default is None else default
             dataclass = type(dataclass)
 
-        new_wrapper = dataclass_wrapper_class(
-            dataclass, dest, prefix=prefix, default=default
-        )
+        new_wrapper = dataclass_wrapper_class(dataclass, dest, prefix=prefix, default=default)
         self._wrappers.append(new_wrapper)
 
     def parse_known_args(
         self,
-        args: Sequence[Text] = None,
+        args: Sequence[str] = None,
         namespace: Namespace = None,
         attempt_to_reorder: bool = False,
     ):
@@ -185,10 +250,49 @@ class ArgumentParser(argparse.ArgumentParser):
         else:
             # make sure that args are mutable
             args = list(args)
+
         self._preprocessing()
 
+        logger.debug(f"Parser {id(self)} is parsing args: {args}, namespace: {namespace}")
+
         parsed_args, unparsed_args = super().parse_known_args(args, namespace)
 
+        if self.subgroups:
+            parser = type(self)(
+                parents=[self],
+                add_help=self._had_help,  # only add help in the child if the parent also had help.
+                add_option_string_dash_variants=self.add_option_string_dash_variants,
+                argument_generation_mode=self.argument_generation_mode,
+                nested_mode=self.nested_mode,
+            )
+
+            for dest, subgroup_dict in self.subgroups.items():
+                value = getattr(parsed_args, dest)
+                logger.debug(f"Chosen value for subgroup {dest}: {value}")
+                # The prefix should be 'thing' instead of 'bob.thing'.
+                # TODO: This needs to be tested more, in particular with argument conflicts.
+                _, _, parent_dest = dest.rpartition(".")
+                prefix = parent_dest + "."
+                if isinstance(value, str):
+                    chosen_class = subgroup_dict[value]
+                    parser.add_arguments(
+                        chosen_class,
+                        dest=dest,
+                        prefix=prefix,
+                    )
+                else:
+                    default = value
+                    chosen_class = type(value)
+                    # prefix = f"{dest}."
+                    parser.add_arguments(
+                        chosen_class,
+                        dest=dest,
+                        prefix=prefix,
+                        default=default,
+                    )
+
+            parsed_args, unparsed_args = parser.parse_known_args(args, namespace)
+
         if unparsed_args and self._subparsers and attempt_to_reorder:
             logger.warning(
                 f"Unparsed arguments when using subparsers. Will "
@@ -205,6 +309,51 @@ class ArgumentParser(argparse.ArgumentParser):
 
     def print_help(self, file=None):
         self._preprocessing()
+        # TODO: Need to also add the args for the chosen subgroups here. Is that possible?
+
+        if self.subgroups:
+            parser = type(self)(
+                parents=[self],
+                add_help=self._had_help,  # only add help in the child if the parent also had help.
+                add_option_string_dash_variants=self.add_option_string_dash_variants,
+                argument_generation_mode=self.argument_generation_mode,
+                nested_mode=self.nested_mode,
+            )
+
+            for dest, subgroup_dict in self.subgroups.items():
+                # Get the default value for that field?
+                field_for_that_subgroup: FieldWrapper = [
+                    field
+                    for wrapper in self._wrappers
+                    for field in wrapper.fields
+                    if field.dest == dest
+                ][0]
+
+                value = field_for_that_subgroup.default
+
+                logger.debug(f"Chosen value for subgroup {dest}: {value}")
+                # The prefix should be 'thing' instead of 'bob.thing'.
+                # TODO: This needs to be tested more, in particular with argument conflicts.
+                _, _, parent_dest = dest.rpartition(".")
+                prefix = parent_dest + "."
+                if isinstance(value, str):
+                    chosen_class = subgroup_dict[value]
+                    parser.add_arguments(
+                        chosen_class,
+                        dest=dest,
+                        prefix=prefix,
+                    )
+                else:
+                    default = value
+                    chosen_class = type(value)
+                    # prefix = f"{dest}."
+                    parser.add_arguments(
+                        chosen_class,
+                        dest=dest,
+                        prefix=prefix,
+                        default=default,
+                    )
+            return parser.print_help(file)
         return super().print_help(file)
 
     def equivalent_argparse_code(self) -> str:
@@ -218,7 +367,7 @@ class ArgumentParser(argparse.ArgumentParser):
             A string containing the auto-generated argparse code.
         """
         self._preprocessing()
-        code = f"parser = ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)"
+        code = "parser = ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)"
         for wrapper in self._wrappers:
             code += "\n"
             code += wrapper.equivalent_argparse_code()
@@ -241,12 +390,55 @@ class ArgumentParser(argparse.ArgumentParser):
         # Create one argument group per dataclass
         for wrapper in self._wrappers:
             logger.debug(
-                f"Adding arguments for dataclass: {wrapper.dataclass} "
+                f"Parser {id(self)} is Adding arguments for dataclass: {wrapper.dataclass} "
                 f"at destinations {wrapper.destinations}"
             )
             wrapper.add_arguments(parser=self)
+
+        self.subgroups: Dict[str, Dict[str, Type]] = {}
+        for wrapper in self._wrappers:
+            for field_wrapper in wrapper.fields:
+                if not field_wrapper.is_subgroup:
+                    continue
+                # NOTE: Need to prevent some weird recursion here.
+                # A field is only supposed to be considered a subgroup if it hasn't already been
+                # resolved by a parent!
+                # IF we are a child, and encounter a subgroup field, then we should check if a
+                # parent already had this field as a subgroup, and in this case, we just parse the
+                # required type.
+                if any(field_wrapper.dest in parent.subgroups for parent in self._parents):
+                    logger.debug(
+                        f"The field {field_wrapper.dest} is a subgroup that has already been "
+                        f"resolved by a parent."
+                    )
+                    continue
+
+                dest = field_wrapper.dest
+                subgroups = field_wrapper.field.metadata["subgroups"]
+                self.subgroups[dest] = subgroups
+
+        self._had_help = self.add_help
+        if self.subgroups and self.add_help:
+            logger.debug("Removing the help action from the parser because it has subgroups.")
+            self.add_help = False
+            self._remove_help_action()
+
         self._preprocessing_done = True
 
+    def _remove_help_action(self) -> None:
+        self.add_help = False
+        help_actions = [action for action in self._actions if isinstance(action, _HelpAction)]
+        if not help_actions:
+            return
+
+        help_action = help_actions[0]
+        self._remove_action(help_action)
+
+        for option_string in self._help_action.option_strings:
+            self._option_string_actions.pop(option_string)
+        # assert False, self._option_string_actions
+        # optionals_help_actions = [action for action in self._optionals._actions]
+
     def _postprocessing(self, parsed_args: Namespace) -> Namespace:
         """Process the namespace by extract the fields and creating the objects.
 
@@ -277,9 +469,7 @@ class ArgumentParser(argparse.ArgumentParser):
         parsed_args = self._set_instances_in_namespace(parsed_args)
         return parsed_args
 
-    def _set_instances_in_namespace(
-        self, parsed_args: argparse.Namespace
-    ) -> argparse.Namespace:
+    def _set_instances_in_namespace(self, parsed_args: argparse.Namespace) -> argparse.Namespace:
         """Create the instances set them at their destination in the namespace.
 
         We now have all the constructor arguments for each instance.
@@ -333,9 +523,7 @@ class ArgumentParser(argparse.ArgumentParser):
                         if arg_value != default_value:
                             all_default_or_none = False
                             break
-                    logger.debug(
-                        f"All fields were either default or None: {all_default_or_none}"
-                    )
+                    logger.debug(f"All fields were either default or None: {all_default_or_none}")
 
                     if all_default_or_none:
                         instance = None
@@ -353,16 +541,28 @@ class ArgumentParser(argparse.ArgumentParser):
                     self.constructor_arguments[parent_key][attr] = instance
 
                 else:
-                    # if this destination is not a nested class, we set the
-                    # attribute on the returned Namespace.
                     # logger.debug(
                     #     f"setting attribute '{destination}' on the Namespace "
                     #     f"to a value of {instance}"
                     # )
-                    assert not hasattr(parsed_args, destination), (
-                        f"Namespace should not already have a '{destination}' "
-                        f"attribute! (namespace: {parsed_args}) "
-                    )
+                    # TODO: Do we want to overwrite the value if it's a subgroup choice?
+                    if hasattr(parsed_args, destination):
+                        # It's ok to overwrite the value of a subgroup choice.
+                        # For instance, say its --optimizer_type = "adam", then we don't save this
+                        # value in the namespace. We overwrite it with the value of the config.
+                        # TODO: I guess we could save the value in the namespace, but where?
+                        # NOTE: This is happening here with the parent of a subgroup field, e.g.
+                        # `Bob(thing="foo")`` is to be overwritten with `Bob(thing=Foo(a=123, b=2))`
+                        if any(
+                            field_wrapper.is_subgroup and field_wrapper.dest.startswith(destination)
+                            for field_wrapper in wrapper.fields
+                        ):
+                            pass
+                        else:
+                            raise RuntimeError(
+                                f"Namespace should not already have a '{destination}' "
+                                f"attribute! (namespace: {parsed_args}) "
+                            )
                     setattr(parsed_args, destination, instance)
 
                 # TODO: not needed, but might be a good thing to do?
@@ -372,9 +572,7 @@ class ArgumentParser(argparse.ArgumentParser):
         assert not self.constructor_arguments
         return parsed_args
 
-    def _consume_constructor_arguments(
-        self, parsed_args: argparse.Namespace
-    ) -> argparse.Namespace:
+    def _consume_constructor_arguments(self, parsed_args: argparse.Namespace) -> argparse.Namespace:
         """Create the constructor arguments for each instance.
 
         Creates the arguments by consuming all the attributes from
diff --git a/simple_parsing/utils.py b/simple_parsing/utils.py
index 825a7b2..be626bc 100644
--- a/simple_parsing/utils.py
+++ b/simple_parsing/utils.py
@@ -1,26 +1,20 @@
 """Utility functions used in various parts of the simple_parsing package."""
 import argparse
 import builtins
-import collections
-import copy
-import inspect
-import enum
-import sys
 import dataclasses
-import functools
-import json
-import re
-import warnings
-import itertools
+import enum
 import hashlib
-from abc import ABC
+import inspect
+import itertools
+import re
+import sys
+import types
+import typing
 from collections import OrderedDict
 from collections import abc as c_abc
 from collections import defaultdict
-from dataclasses import _MISSING_TYPE, MISSING, Field, dataclass
+from dataclasses import _MISSING_TYPE, Field
 from enum import Enum
-from functools import partial
-from inspect import isclass
 from logging import getLogger
 from typing import (
     Any,
@@ -38,8 +32,6 @@ from typing import (
     TypeVar,
     Union,
 )
-import typing
-from typing import get_type_hints
 
 # from typing_inspect import get_origin, is_typevar, get_bound, is_forward_ref, get_forward_arg
 NEW_TYPING = sys.version_info[:3] >= (3, 7, 0)  # PEP 560
@@ -47,11 +39,11 @@ NEW_TYPING = sys.version_info[:3] >= (3, 7, 0)  # PEP 560
 if sys.version_info < (3, 9):
     # TODO: Add 3.9 compatibility, remove typing_inspect dependency.
     from typing_inspect import (
-        get_origin,
-        is_typevar,
         get_bound,
         get_forward_arg,
+        get_origin,
         is_forward_ref,
+        is_typevar,
     )
 else:
     from typing import get_origin
@@ -86,18 +78,10 @@ except ImportError:
         return getattr(some_type, "__args__", ())
 
 
-try:
-    from typing import get_origin
-except ImportError:
-    from typing_inspect import get_origin
-
-
 logger = getLogger(__name__)
 
 builtin_types = [
-    getattr(builtins, d)
-    for d in dir(builtins)
-    if isinstance(getattr(builtins, d), type)
+    getattr(builtins, d) for d in dir(builtins) if isinstance(getattr(builtins, d), type)
 ]
 
 K = TypeVar("K")
@@ -110,9 +94,7 @@ Dataclass = TypeVar("Dataclass")
 DataclassType = Type[Dataclass]
 
 SimpleValueType = Union[bool, int, float, str]
-SimpleIterable = Union[
-    List[SimpleValueType], Dict[Any, SimpleValueType], Set[SimpleValueType]
-]
+SimpleIterable = Union[List[SimpleValueType], Dict[Any, SimpleValueType], Set[SimpleValueType]]
 
 
 def is_subparser_field(field: Field) -> bool:
@@ -408,9 +390,7 @@ def is_dataclass_type(t: Type) -> bool:
     Returns:
         bool: Whether its a dataclass type.
     """
-    return dataclasses.is_dataclass(t) or (
-        is_typevar(t) and dataclasses.is_dataclass(get_bound(t))
-    )
+    return dataclasses.is_dataclass(t) or (is_typevar(t) and dataclasses.is_dataclass(get_bound(t)))
 
 
 def is_enum(t: Type) -> bool:
@@ -444,6 +424,8 @@ def is_union(t: Type) -> bool:
     >>> is_union(Tuple[int, str])
     False
     """
+    if sys.version_info[:2] >= (3, 10) and isinstance(t, types.UnionType):
+        return True
     return getattr(t, "__origin__", "") == Union
 
 
@@ -541,9 +523,7 @@ def get_dataclass_type_arg(t: Type) -> Optional[Type]:
         return t
     elif is_tuple_or_list(t) or is_union(t):
         return next(
-            filter(
-                None, (get_dataclass_type_arg(arg) for arg in get_type_arguments(t))
-            ),
+            filter(None, (get_dataclass_type_arg(arg) for arg in get_type_arguments(t))),
             None,
         )
     return None
@@ -608,9 +588,7 @@ def get_container_nargs(container_type: Type) -> Union[int, str]:
 
     if is_list(container_type):
         return "*"
-    raise NotImplementedError(
-        f"Not sure what 'nargs' should be for type {container_type}"
-    )
+    raise NotImplementedError(f"Not sure what 'nargs' should be for type {container_type}")
 
 
 def _parse_multiple_containers(
@@ -622,6 +600,7 @@ def _parse_multiple_containers(
     result = factory()
 
     def parse_fn(value: str):
+        nonlocal result
         logger.debug(f"parsing multiple {container_type} of {T}s, value is: '{value}'")
         values = _parse_container(container_type)(value)
         logger.debug(f"parsing result is '{values}'")
@@ -647,9 +626,7 @@ def _parse_container(container_type: Type[Container]) -> Callable[[str], List[An
         try:
             values = _parse_literal(value)
         except Exception as e:
-            logger.debug(
-                f"Exception while trying to parse '{value}' as a literal: {type(e)}: {e}"
-            )
+            logger.debug(f"Exception while trying to parse '{value}' as a literal: {type(e)}: {e}")
             # if it doesn't work, fall back to the parse_fn.
             values = _fallback_parse(value)
 
@@ -834,9 +811,7 @@ def zip_dicts(*dicts: Dict[K, V]) -> Iterable[Tuple[K, Tuple[Optional[V], ...]]]
         yield (key, tuple(d.get(key) for d in dicts))
 
 
-def dict_union(
-    *dicts: Dict[K, V], recurse: bool = True, dict_factory=dict
-) -> Dict[K, V]:
+def dict_union(*dicts: Dict[K, V], recurse: bool = True, dict_factory=dict) -> Dict[K, V]:
     """Simple dict union until we use python 3.9
 
     If `recurse` is True, also does the union of nested dictionaries.
@@ -884,203 +859,6 @@ def dict_union(
     return result
 
 
-# NOTE: This dict is used to enable forward compatibility with things such as `tuple[int, str]`,
-# `list[float]`, etc. when using `from __future__ import annotations`.
-forward_refs_to_types = {
-    "tuple": typing.Tuple,
-    "set": typing.Set,
-    "dict": typing.Dict,
-    "list": typing.List,
-    "type": Type,
-}
-
-
-def get_field_type_from_annotations(some_class: type, field_name: str) -> type:
-    """
-    If the script uses `from __future__ import annotations`, and we are in python<3.9,
-    Then we need to actually first make this forward-compatibility 'patch' so that we
-    don't run into a "`type` object is not subscriptable" error.
-
-    NOTE: If you get errors of this kind from the function below, then you might want to add an
-    entry to the `forward_refs_to_types` dict above.
-    """
-    # The type of the field might be a string when using `from __future__ import annotations`.
-    # Get the local and global namespaces to pass to the `get_type_hints` function.
-    local_ns: Dict[str, Any] = {"typing": typing, **vars(typing)}
-    if sys.version_info < (3, 9):
-        local_ns.update(forward_refs_to_types)
-    global_ns = sys.modules[some_class.__module__].__dict__
-    try:
-        class_type_hints = get_type_hints(
-            some_class, localns=local_ns, globalns=global_ns
-        )
-        field_type = class_type_hints[field_name]
-
-        if sys.version_info >= (3, 10):
-            # In python >= 3.10, int | float is allowed. Therefore, just to be consistent, we want
-            # to convert those into the corresponding typing.Union type.
-            args = typing.get_args(field_type)
-            new_args = []
-
-            import types
-
-            def _replace_UnionTypes_with_typing_Union(annotation):
-                if isinstance(annotation, types.UnionType):
-                    union_args = typing.get_args(annotation)
-                    new_union_args = tuple(
-                        _replace_UnionTypes_with_typing_Union(arg) for arg in union_args
-                    )
-                    return typing.Union[new_union_args]
-                if is_list(annotation):
-                    item_annotation = typing.get_args(annotation)[0]
-                    new_item_annotation = _replace_UnionTypes_with_typing_Union(
-                        item_annotation
-                    )
-                    return typing.List[new_item_annotation]
-                if is_tuple(annotation):
-                    item_annotations = typing.get_args(annotation)
-                    new_item_annotations = tuple(
-                        _replace_UnionTypes_with_typing_Union(arg)
-                        for arg in item_annotations
-                    )
-                    return typing.Tuple[new_item_annotations]
-                if is_dict(annotation):
-                    annotations = typing.get_args(annotation)
-                    if not annotations:
-                        return typing.Dict
-                    assert len(annotations) == 2
-                    key_annotation = annotations[0]
-                    value_annotation = annotations[1]
-                    new_key_annotation = _replace_UnionTypes_with_typing_Union(
-                        key_annotation
-                    )
-                    new_value_annotation = _replace_UnionTypes_with_typing_Union(
-                        value_annotation
-                    )
-                    return typing.Dict[new_key_annotation, new_value_annotation]
-                if annotation in builtin_types:
-                    return annotation
-                if inspect.isclass(annotation):
-                    return annotation
-                raise NotImplementedError(annotation)
-
-            new_field_type = _replace_UnionTypes_with_typing_Union(field_type)
-            field_type = new_field_type
-
-    except TypeError as err:
-        annotations_dict = some_class.__annotations__.copy()
-        if any(
-            isinstance(annotation, str) and "|" in annotation
-            for annotation in annotations_dict.values()
-        ):
-            # Pretty hacky: Modify the type annotations of the class (preferably a copy of the class
-            # if possible, to avoid modifying things in-place), and replace  the `a | b`-type
-            # expressions with `Union[a, b]`, so that `get_type_hints` doesn't raise an error.
-            try:
-                before = some_class.__annotations__.copy()
-                after = _replace_new_union_syntax_with_old_union_syntax(
-                    before, local_ns=local_ns, global_ns=global_ns
-                )
-
-                class _Temp:
-                    pass
-
-                _Temp.__annotations__ = after
-                class_type_hints = get_type_hints(
-                    _Temp, localns=local_ns, globalns=global_ns
-                )
-                field_type = class_type_hints[field_name]
-                return field_type
-            except (TypeError, NotImplementedError) as exc:
-                print(err)
-                err = exc
-                pass
-        annotation = annotations_dict[field_name]
-        logger.error(
-            f"Error when parsing type annotations of class {some_class}: {err}\n"
-            f"Using a 'str' type for the field instead of the original type: {annotation}).\n"
-            f"Please make an issue at https://www.github.com/lebrice/SimpleParsing/issues "
-            f"if you believe this annotation should be supported explicitly."
-        )
-        return str
-    if sys.version_info[:2] >= (3, 7):
-        # Weird bug happens when mixing postponed evaluation of type annotations + forward
-        # references: The ForwardRefs are left as-is, and not evaluated!
-        from typing import ForwardRef
-
-        if isinstance(field_type, ForwardRef):
-            forward_arg = field_type.__forward_arg__
-            if forward_arg in global_ns:
-                field_type = global_ns[forward_arg]
-            else:
-                logger.warning(
-                    f"Unable to evaluate forward reference {field_type} for field '{field_name}'.\n"
-                    f"Leaving it as-is."
-                )
-    return field_type
-
-
-def _replace_new_union_syntax_with_old_union_syntax(
-    annotations_dict: Dict[str, str], local_ns: dict, global_ns: dict
-) -> Dict[str, Any]:
-    # Pretty hacky: Modify the type annotations of the class (preferably a copy of the class
-    # if possible, to avoid modifying things in-place), and replace  the `a | b`-type
-    # expressions with `Union[a, b]`, so that `get_type_hints` doesn't raise an error.
-    # The type of the field might be a string when using `from __future__ import annotations`.
-
-    import builtins
-
-    def _get_type(ann: str) -> Union[type, str]:
-        # Try locals, then globals, then builtins. Otherwise, use the annotation itself.
-        maps = collections.ChainMap(
-            forward_refs_to_types, local_ns, global_ns, builtins.__dict__
-        )
-        return maps.get(ann, ann)
-        # return forward_refs_to_types.get(ann, local_ns.get(ann, global_ns.get(ann, getattr(builtins, ann, ann))))
-
-    def _not_supported() -> typing.NoReturn:
-        raise NotImplementedError(
-            f"Don't yet support annotations like these: {annotations_dict}"
-        )
-
-    def _get_old_style_annotation(annotation: str) -> str:
-        # TODO: Add proper support for things like `list[int | float]`, which isn't currently
-        # working, even without the new-style union.
-        if "|" not in annotation:
-            return annotation
-
-        annotation = annotation.strip()
-        if "[" not in annotation:
-            assert "]" not in annotation
-            return "Union[" + ", ".join(v.strip() for v in annotation.split("|")) + "]"
-
-        before, lsep, rest = annotation.partition("[")
-        middle, rsep, after = rest.rpartition("]")
-        assert (
-            not after.strip()
-        ), "can't have text at HERE in <something>[<something>]<HERE>!"
-
-        if "|" in before or "|" in after:
-            _not_supported()
-        assert "|" in middle
-
-        if "," in middle:
-            parts = [v.strip() for v in middle.split(",")]
-            parts = [_get_old_style_annotation(part) for part in parts]
-            middle = ", ".join(parts)
-
-        new_middle = _get_old_style_annotation(annotation=middle)
-        new_annotation = str(_get_type(before)) + lsep + new_middle + rsep + after
-        return new_annotation
-
-    new_annotations = annotations_dict.copy()
-    for field, annotation_str in annotations_dict.items():
-        updated_annotation = _get_old_style_annotation(annotation_str)
-        new_annotations[field] = updated_annotation
-
-    return new_annotations
-
-
 if __name__ == "__main__":
     import doctest
 
diff --git a/simple_parsing/wrappers/dataclass_wrapper.py b/simple_parsing/wrappers/dataclass_wrapper.py
index d78fbfb..bc06251 100644
--- a/simple_parsing/wrappers/dataclass_wrapper.py
+++ b/simple_parsing/wrappers/dataclass_wrapper.py
@@ -1,10 +1,8 @@
 import argparse
-import sys
 import dataclasses
 from dataclasses import _MISSING_TYPE
 from logging import getLogger
-from typing import Dict, List, Optional, Type, Union, cast, get_type_hints
-import typing
+from typing import Dict, List, Optional, Type, Union, cast
 
 from .. import docstring, utils
 from ..utils import Dataclass
@@ -55,9 +53,11 @@ class DataclassWrapper(Wrapper[Dataclass]):
             if isinstance(field.type, str):
                 # NOTE: Here we'd like to convert the fields type to an actual type, in case the
                 # `from __future__ import annotations` feature is used.
-                field_type = utils.get_field_type_from_annotations(
-                    self.dataclass, field.name
+                from simple_parsing.annotation_utils.get_field_annotations import (
+                    get_field_type_from_annotations,
                 )
+
+                field_type = get_field_type_from_annotations(self.dataclass, field.name)
                 # Modify the `type` of the Field object, in-place.
                 field.type = field_type
 
@@ -75,7 +75,11 @@ class DataclassWrapper(Wrapper[Dataclass]):
                 # handle a nested dataclass attribute
                 dataclass, name = field.type, field.name
                 child_wrapper = DataclassWrapper(
-                    dataclass, name, parent=self, _field=field, default=field_default,
+                    dataclass,
+                    name,
+                    parent=self,
+                    _field=field,
+                    default=field_default,
                 )
                 self._children.append(child_wrapper)
 
@@ -94,44 +98,38 @@ class DataclassWrapper(Wrapper[Dataclass]):
 
             else:
                 # a normal attribute
-                field_wrapper = field_wrapper_class(
-                    field, parent=self, prefix=self.prefix
-                )
+                field_wrapper = field_wrapper_class(field, parent=self, prefix=self.prefix)
                 logger.debug(
                     f"wrapped field at {field_wrapper.dest} has a default value of {field_wrapper.default}"
                 )
                 self.fields.append(field_wrapper)
 
-        logger.debug(
-            f"The dataclass at attribute {self.dest} has default values: {self.defaults}"
-        )
+        logger.debug(f"The dataclass at attribute {self.dest} has default values: {self.defaults}")
 
     def add_arguments(self, parser: argparse.ArgumentParser):
         from ..parsing import ArgumentParser
 
         parser = cast(ArgumentParser, parser)
 
-        group = parser.add_argument_group(
-            title=self.title, description=self.description
-        )
+        group = parser.add_argument_group(title=self.title, description=self.description)
 
         for wrapped_field in self.fields:
             if not wrapped_field.field.metadata.get("cmd", True):
-                logger.debug(
-                    f"Skipping field {wrapped_field.name} because it has cmd=False."
-                )
+                logger.debug(f"Skipping field {wrapped_field.name} because it has cmd=False.")
                 continue
 
             if wrapped_field.is_subparser:
                 wrapped_field.add_subparsers(parser)
 
+            # if wrapped_field.is_subgroup:
+            #     pass  # What to do in that case? Just add it like a regular `choice` argument?
+            # wrapped_field.add_subparsers(parser)
+
             elif wrapped_field.arg_options:
                 logger.debug(
                     f"Arg options for field '{wrapped_field.name}': {wrapped_field.arg_options}"
                 )
-                group.add_argument(
-                    *wrapped_field.option_strings, **wrapped_field.arg_options
-                )
+                group.add_argument(*wrapped_field.option_strings, **wrapped_field.arg_options)
 
     def equivalent_argparse_code(self, leading="group") -> str:
         code = ""
@@ -210,9 +208,7 @@ class DataclassWrapper(Wrapper[Dataclass]):
     @property
     def description(self) -> str:
         if self.parent and self._field:
-            doc = docstring.get_attribute_docstring(
-                self.parent.dataclass, self._field.name
-            )
+            doc = docstring.get_attribute_docstring(self.parent.dataclass, self._field.name)
             if doc is not None:
                 if doc.docstring_below:
                     return doc.docstring_below
@@ -271,9 +267,7 @@ class DataclassWrapper(Wrapper[Dataclass]):
     def destinations(self) -> List[str]:
         if not self._destinations:
             if self.parent:
-                self._destinations = [
-                    f"{d}.{self.name}" for d in self.parent.destinations
-                ]
+                self._destinations = [f"{d}.{self.name}" for d in self.parent.destinations]
             else:
                 self._destinations = [self.name]
         return self._destinations
diff --git a/simple_parsing/wrappers/field_wrapper.py b/simple_parsing/wrappers/field_wrapper.py
index 9f12058..cb32457 100644
--- a/simple_parsing/wrappers/field_wrapper.py
+++ b/simple_parsing/wrappers/field_wrapper.py
@@ -25,6 +25,7 @@ class ArgumentGenerationMode(Enum):
     """
     Enum for argument generation modes.
     """
+
     # Tries to generate flat arguments, removing the argument destination path when possible.
     FLAT = auto()
     # Generates arguments with their full destination path.
@@ -37,6 +38,7 @@ class NestedMode(Enum):
     """
     Controls how nested arguments are generated.
     """
+
     # By default, the full destination path is used.
     DEFAULT = auto()
     # The full destination path is used, but the first level is removed.
@@ -263,9 +265,7 @@ class FieldWrapper(Wrapper[dataclasses.Field]):
                 _arg_options["nargs"] = utils.get_container_nargs(wrapped_type)
 
             elif utils.is_list(wrapped_type):
-                _arg_options["type"] = utils.get_argparse_type_for_container(
-                    wrapped_type
-                )
+                _arg_options["type"] = utils.get_argparse_type_for_container(wrapped_type)
                 _arg_options["nargs"] = "*"
                 # NOTE: Can't set 'const', since we'd get:
                 # ValueError: nargs must be '?' to supply const
@@ -294,9 +294,7 @@ class FieldWrapper(Wrapper[dataclasses.Field]):
                     return e.name if isinstance(e, Enum) else e
 
                 if self.is_reused:
-                    _arg_options["default"] = [
-                        enum_to_str(default) for default in self.default
-                    ]
+                    _arg_options["default"] = [enum_to_str(default) for default in self.default]
                 else:
                     _arg_options["default"] = enum_to_str(self.default)
 
@@ -314,9 +312,7 @@ class FieldWrapper(Wrapper[dataclasses.Field]):
                 _arg_options["type"] = utils.get_argparse_type_for_container(self.type)
 
         elif utils.is_tuple(self.type):
-            logger.debug(
-                f"Adding a Tuple attribute '{self.name}' with type {self.type}"
-            )
+            logger.debug(f"Adding a Tuple attribute '{self.name}' with type {self.type}")
             _arg_options["nargs"] = utils.get_container_nargs(self.type)
             _arg_options["type"] = get_parsing_fn(self.type)
 
@@ -339,9 +335,7 @@ class FieldWrapper(Wrapper[dataclasses.Field]):
             else:
                 # Remove the 'metavar' that we auto-generated above.
                 _arg_options.pop("metavar", None)
-            _arg_options["type"] = self.custom_arg_options.get(
-                "type", get_parsing_fn(self.type)
-            )
+            _arg_options["type"] = self.custom_arg_options.get("type", get_parsing_fn(self.type))
 
         if self.is_reused:
             if self.required:
@@ -516,9 +510,7 @@ class FieldWrapper(Wrapper[dataclasses.Field]):
 
     @property
     def destinations(self) -> List[str]:
-        return [
-            f"{parent_dest}.{self.name}" for parent_dest in self.parent.destinations
-        ]
+        return [f"{parent_dest}.{self.name}" for parent_dest in self.parent.destinations]
 
     @property
     def option_strings(self) -> List[str]:
@@ -543,7 +535,7 @@ class FieldWrapper(Wrapper[dataclasses.Field]):
         dashes: List[str] = []  # contains the leading dashes.
         options: List[str] = []  # contains the name following the dashes.
 
-        def add_args(dash:str, candidates: List[str]) -> None:
+        def add_args(dash: str, candidates: List[str]) -> None:
             for candidate in candidates:
                 options.append(candidate)
                 dashes.append(dash)
@@ -556,7 +548,9 @@ class FieldWrapper(Wrapper[dataclasses.Field]):
 
         dash = "-" if len(self.name) == 1 else "--"
         option = f"{self.prefix}{self.name}"
-        nested_option = self.dest if nested_mode == NestedMode.DEFAULT else ".".join(self.dest.split(".")[1:])
+        nested_option = (
+            self.dest if nested_mode == NestedMode.DEFAULT else ".".join(self.dest.split(".")[1:])
+        )
         if add_dash_variants == DashVariant.DASH:
             option = option.replace("_", "-")
             nested_option = nested_option.replace("_", "-")
@@ -599,12 +593,8 @@ class FieldWrapper(Wrapper[dataclasses.Field]):
         # even if an alias isn't explicitly created.
 
         if add_dash_variants == DashVariant.UNDERSCORE_AND_DASH:
-            additional_options = [
-                option.replace("_", "-") for option in options if "_" in option
-            ]
-            additional_dashes = [
-                "-" if len(option) == 1 else "--" for option in additional_options
-            ]
+            additional_options = [option.replace("_", "-") for option in options if "_" in option]
+            additional_dashes = ["-" if len(option) == 1 else "--" for option in additional_options]
             options.extend(additional_options)
             dashes.extend(additional_dashes)
 
@@ -772,7 +762,9 @@ class FieldWrapper(Wrapper[dataclasses.Field]):
                 # NOTE: Here we'd like to convert the fields type to an actual type, in case the
                 # `from __future__ import annotations` feature is used.
                 # This should also resolve most forward references.
-                field_type = utils.get_field_type_from_annotations(self.parent.dataclass, self.field.name)
+                field_type = utils.get_field_type_from_annotations(
+                    self.parent.dataclass, self.field.name
+                )
                 self._type = field_type
 
             if self.is_choice and self.choice_dict:
@@ -815,9 +807,7 @@ class FieldWrapper(Wrapper[dataclasses.Field]):
                 self.parent.dataclass, self.field.name
             )
         except (SystemExit, Exception) as e:
-            logger.debug(
-                f"Couldn't find attribute docstring for field {self.name}, {e}"
-            )
+            logger.debug(f"Couldn't find attribute docstring for field {self.name}, {e}")
             self._docstring = docstring.AttributeDocString()
 
         if self._docstring.docstring_below:
@@ -876,7 +866,11 @@ class FieldWrapper(Wrapper[dataclasses.Field]):
 
     @property
     def is_subparser(self) -> bool:
-        return utils.is_subparser_field(self.field)
+        return utils.is_subparser_field(self.field) and "subgroups" not in self.field.metadata
+
+    @property
+    def is_subgroup(self) -> bool:
+        return "subgroups" in self.field.metadata
 
     @property
     def type_arguments(self) -> Optional[Tuple[Type, ...]]:
@@ -946,9 +940,7 @@ class FieldWrapper(Wrapper[dataclasses.Field]):
         return f"group.add_argument(*{self.option_strings}, **{arg_options_string})"
 
 
-def only_keep_action_args(
-    options: Dict[str, Any], action: Union[str, Any]
-) -> Dict[str, Any]:
+def only_keep_action_args(options: Dict[str, Any], action: Union[str, Any]) -> Dict[str, Any]:
     """Remove all the arguments in `options` that aren't required by the Action.
 
     Parameters


Test patch:
diff --git a/test/conftest.py b/test/conftest.py
index 2a2b8ba..43a29dc 100644
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -1,9 +1,7 @@
 import logging
-from pathlib import Path
-import shlex
 import sys
-from dataclasses import dataclass, field
-from typing import *
+from dataclasses import dataclass
+from typing import Any, ClassVar, List, Optional, Tuple, Type
 
 import pytest
 
@@ -13,7 +11,6 @@ pytest.register_assert_rewrite("test.testutils")
 from simple_parsing import choice
 from simple_parsing.helpers import Serializable
 
-
 collect_ignore = []
 if sys.version_info < (3, 7):
     collect_ignore.append("test_future_annotations.py")
@@ -71,6 +68,30 @@ def assert_equals_stdout(capsys):
     return should_equal
 
 
+from logging import getLogger as get_logger
+
+
+@pytest.fixture(scope="session", autouse=True)
+def setup_logging():
+    project_logger = get_logger("simple_parsing")
+    project_logger.setLevel(
+        logging.DEBUG
+        if "-vv" in sys.argv
+        else logging.INFO
+        if "-v" in sys.argv
+        else logging.WARNING
+    )
+    ch = logging.StreamHandler(stream=sys.stdout)
+    ch.setFormatter(
+        logging.Formatter(
+            "[%(asctime)s] p%(process)s {%(pathname)s:%(lineno)d} %(levelname)s - %(message)s",
+            "%m-%d %H:%M:%S"
+            # "%(asctime)-15s::%(levelname)s::%(pathname)s::%(lineno)d::%(message)s"
+        )
+    )
+    project_logger.addHandler(ch)
+
+
 @pytest.fixture(scope="module")
 def parser():
     from .testutils import TestParser
@@ -99,13 +120,9 @@ def no_stdout(capsys, caplog):
 def no_warnings(caplog):
     yield
     for when in ("setup", "call"):
-        messages = [
-            x.message for x in caplog.get_records(when) if x.levelno == logging.WARNING
-        ]
+        messages = [x.message for x in caplog.get_records(when) if x.levelno == logging.WARNING]
         if messages:
-            pytest.fail(
-                "warning messages encountered during testing: {}".format(messages)
-            )
+            pytest.fail(f"warning messages encountered during testing: {messages}")
 
 
 @pytest.fixture
@@ -114,15 +131,12 @@ def silent(no_stdout, no_warnings):
     Test fixture that will make a test fail if it prints anything to stdout or
     logs warnings
     """
-    pass
 
 
 @pytest.fixture
 def logs_warning(caplog):
     yield
-    messages = [
-        x.message for x in caplog.get_records("call") if x.levelno == logging.WARNING
-    ]
+    messages = [x.message for x in caplog.get_records("call") if x.levelno == logging.WARNING]
     if not messages:
         pytest.fail(f"No warning messages were logged: {messages}")
 
@@ -143,17 +157,13 @@ def TaskHyperParameters():
         name: str  # name of the task
         num_layers: int = 1  # number of dense layers
         num_units: int = 8  # units per layer
-        activation: str = choice(
-            "tanh", "relu", "linear", default="tanh"
-        )  # activation function
+        activation: str = choice("tanh", "relu", "linear", default="tanh")  # activation function
         use_batchnorm: bool = (
             False  # whether or not to use batch normalization after each dense layer
         )
         use_dropout: bool = True  # whether or not to use dropout after each dense layer
         dropout_rate: float = 0.1  # the dropout rate
-        use_image_features: bool = (
-            True  # whether or not image features should be used as input
-        )
+        use_image_features: bool = True  # whether or not image features should be used as input
         use_likes: bool = True  # whether or not 'likes' features should be used as input
         l1_reg: float = 0.005  # L1 regularization coefficient
         l2_reg: float = 0.005  # L2 regularization coefficient
diff --git a/test/test_custom_args.py b/test/test_custom_args.py
index 3782e56..9303306 100644
--- a/test/test_custom_args.py
+++ b/test/test_custom_args.py
@@ -1,23 +1,28 @@
 import argparse
-from pyexpat import version_info
-import shlex
-from dataclasses import dataclass
-from typing import Any
-
-import pytest
-from simple_parsing import ArgumentParser, field, DashVariant
-from simple_parsing.wrappers.field_wrapper import FieldWrapper
 import textwrap
-from .testutils import *
+from dataclasses import dataclass
 from typing import List
 
+from simple_parsing import ArgumentParser, DashVariant, field
+
+from .testutils import (
+    ArgumentGenerationMode,
+    NestedMode,
+    TestParser,
+    TestSetup,
+    assert_help_output_equals,
+    exits_and_writes_to_stderr,
+    raises,
+    raises_expected_n_args,
+    raises_missing_required_arg,
+    raises_unrecognized_args,
+)
+
 
 def test_custom_args():
     @dataclass
     class Foo(TestSetup):
-        output_dir: str = field(
-            default="/out", alias=["-o", "--out"], choices=["/out", "/bob"]
-        )
+        output_dir: str = field(default="/out", alias=["-o", "--out"], choices=["/out", "/bob"])
 
     foo = Foo.setup("--output_dir /bob")
     assert foo.output_dir == "/bob"
@@ -98,7 +103,7 @@ def test_custom_nargs_star():
         some_int: int = field(type=int, nargs="*")
 
     foo = Foo.setup("")
-    assert foo.some_int == None
+    assert foo.some_int is None
 
     foo = Foo.setup("--some_int")
     assert foo.some_int == []
@@ -119,7 +124,7 @@ def test_custom_nargs_question_mark():
     assert foo.some_int == -1
 
     foo = Foo.setup("--some_int")
-    assert foo.some_int == None
+    assert foo.some_int is None
 
     foo = Foo.setup("--some_int 123")
     assert foo.some_int == 123
@@ -170,7 +175,6 @@ def test_only_dashes():
 
         a_var: int
 
-
     @dataclass
     class SomeClass(TestSetup):
         """lol"""
@@ -196,26 +200,29 @@ def test_only_dashes():
               foo
             
               --a-var int
-            """
+            """  # noqa: W293
         ),
     )
     sc = SomeClass.setup("--my-var 2 --a-var 3", add_option_string_dash_variants=DashVariant.DASH)
     assert sc.my_var == 2
     assert sc.a.a_var == 3
-    sc = SomeClass.setup("--some-class.my-var 2 --some-class.a.a-var 3",
-                         add_option_string_dash_variants=DashVariant.DASH,
-                         argument_generation_mode=ArgumentGenerationMode.NESTED)
+    sc = SomeClass.setup(
+        "--some-class.my-var 2 --some-class.a.a-var 3",
+        add_option_string_dash_variants=DashVariant.DASH,
+        argument_generation_mode=ArgumentGenerationMode.NESTED,
+    )
     assert sc.my_var == 2
     assert sc.a.a_var == 3
-    sc = SomeClass.setup("--my-var 2 --a.a-var 3",
-                         add_option_string_dash_variants=DashVariant.DASH,
-                         argument_generation_mode=ArgumentGenerationMode.NESTED,
-                         nested_mode=NestedMode.WITHOUT_ROOT)
+    sc = SomeClass.setup(
+        "--my-var 2 --a.a-var 3",
+        add_option_string_dash_variants=DashVariant.DASH,
+        argument_generation_mode=ArgumentGenerationMode.NESTED,
+        nested_mode=NestedMode.WITHOUT_ROOT,
+    )
     assert sc.my_var == 2
     assert sc.a.a_var == 3
 
 
-
 def test_list_of_choices():
     @dataclass
     class Foo(TestSetup):
diff --git a/test/test_default_args.py b/test/test_default_args.py
index 5a8205f..2f4030f 100644
--- a/test/test_default_args.py
+++ b/test/test_default_args.py
@@ -1,6 +1,8 @@
 from dataclasses import dataclass
 
-from .testutils import *
+from simple_parsing import ArgumentParser
+
+from .testutils import argparse, raises_missing_required_arg, shlex
 
 
 def test_no_default_argument(simple_attribute, silent):
@@ -28,9 +30,7 @@ def test_default_dataclass_argument(simple_attribute, silent):
         a: some_type
 
     parser = ArgumentParser()
-    parser.add_arguments(
-        SomeClass, dest="some_class", default=SomeClass(a=expected_value)
-    )
+    parser.add_arguments(SomeClass, dest="some_class", default=SomeClass(a=expected_value))
 
     args = parser.parse_args("")
     assert args == argparse.Namespace(some_class=SomeClass(a=expected_value))
diff --git a/test/test_examples.py b/test/test_examples.py
index f81e057..844b8da 100644
--- a/test/test_examples.py
+++ b/test/test_examples.py
@@ -2,15 +2,13 @@
 (Could be seen as a kind of integration test.)
 
 """
-from collections import Counter
-import contextlib
 import glob
-import importlib
 import shlex
 import sys
-from io import StringIO
-from typing import Callable, Optional
+from collections import Counter
 from pathlib import Path
+from typing import Callable, Optional
+
 import pytest
 
 expected = ""
@@ -33,16 +31,11 @@ def set_prog_name():
     sys.argv = argv
 
 
-import textwrap
-
-
 @pytest.fixture
 def assert_equals_stdout(capsys):
     def strip(string):
         return "".join(string.split())
 
-    import difflib
-
     def should_equal(expected: str, file_path: str):
         if "optional arguments" in expected and sys.version_info >= (3, 10):
             expected = expected.replace("optional arguments", "options")
@@ -55,7 +48,11 @@ def assert_equals_stdout(capsys):
         expected_lines = [line.strip() for line in expected_lines if line and not line.isspace()]
         if sys.version_info[:2] >= (3, 9):
             # Ordering of values in a `Namespace` object are different!
-            assert Counter("".join(out_lines)) == Counter("".join(expected_lines)), (
+            expected = Counter("".join(expected_lines))
+            actual = Counter("".join(out_lines))
+            actual[" "] = 0
+            expected[" "] = 0
+            assert actual == expected, (
                 out_lines,
                 expected_lines,
             )
@@ -106,7 +103,20 @@ def test_running_example_outputs_expected(
     "file_path",
     [
         *[
-            p
+            pytest.param(
+                p,
+                marks=[
+                    pytest.mark.skipif(
+                        sys.version_info[:2] == (3, 6),
+                        reason="Example uses __future__ annotations feature",
+                    ),
+                    pytest.mark.xfail(
+                        reason="Example has different indentation depending on python version.",
+                    ),
+                ],
+            )
+            if p == "examples/subgroups/subgroups_example.py"
+            else p
             for p in glob.glob("examples/**/*.py")
             if p
             not in {
@@ -122,6 +132,4 @@ def test_running_example_outputs_expected_without_arg(
     set_prog_name: Callable[[str, Optional[str]], None],
     assert_equals_stdout: Callable[[str, str], None],
 ):
-    return test_running_example_outputs_expected(
-        file_path, "", set_prog_name, assert_equals_stdout
-    )
+    return test_running_example_outputs_expected(file_path, "", set_prog_name, assert_equals_stdout)
diff --git a/test/test_future_annotations.py b/test/test_future_annotations.py
index d165cbc..eb5024c 100644
--- a/test/test_future_annotations.py
+++ b/test/test_future_annotations.py
@@ -1,13 +1,18 @@
 """ Tests for compatibility with the postponed evaluation of annotations. """
 from __future__ import annotations
-import typing
-import pytest
+
 import dataclasses
-import sys
+import typing
 from dataclasses import dataclass
 from typing import Any, Callable, Generic, TypeVar
+
 import pytest
+
 from simple_parsing import field
+from simple_parsing.annotation_utils.get_field_annotations import (
+    get_field_type_from_annotations,
+)
+from simple_parsing.utils import is_list, is_tuple
 
 from .testutils import TestSetup
 
@@ -25,7 +30,7 @@ class Foo(TestSetup):
 @dataclass
 class Bar(TestSetup):
     barry: Foo = field(default_factory=Foo)
-    joe: "Foo" = field(default_factory=lambda: Foo(b="rrrrr"))
+    joe: Foo = field(default_factory=lambda: Foo(b="rrrrr"))
     z: float = 123.456
     some_list: list[float] = field(default_factory=[1.0, 2.0].copy)
 
@@ -58,8 +63,6 @@ class OtherClassWithNewUnionSyntax(ClassWithNewUnionSyntax):
     base class.
     """
 
-    pass
-
 
 @pytest.mark.parametrize(
     "ClassWithNewUnionSyntax", [ClassWithNewUnionSyntax, OtherClassWithNewUnionSyntax]
@@ -69,9 +72,7 @@ def test_new_union_syntax(ClassWithNewUnionSyntax: type[ClassWithNewUnionSyntax]
     assert ClassWithNewUnionSyntax.setup("--v 456") == ClassWithNewUnionSyntax(v=456)
     assert ClassWithNewUnionSyntax.setup("--v 4.56") == ClassWithNewUnionSyntax(v=4.56)
 
-    field_annotations = {
-        f.name: f.type for f in dataclasses.fields(ClassWithNewUnionSyntax)
-    }
+    field_annotations = {f.name: f.type for f in dataclasses.fields(ClassWithNewUnionSyntax)}
     from simple_parsing.utils import is_union
 
     assert is_union(field_annotations["v"])
@@ -90,7 +91,6 @@ def test_more_complicated_unions():
     These values are then used to modify the `type` attribute of the `dataclasses.Field` objects
     on the class, *in-place*, so that simple-parsing can work just like before.
     """
-    from simple_parsing.utils import is_list, is_tuple, get_field_type_from_annotations
 
     # T = TypeVarTuple("T")  # TODO: Use this eventually (when it becomes possible).
     T = TypeVar("T")
@@ -129,7 +129,7 @@ def test_more_complicated_unions():
 
         def none_worked(self, exceptions: list[Exception]) -> typing.NoReturn:
             raise RuntimeError(
-                f"None of the functions worked!\n"
+                "None of the functions worked!\n"
                 + "\n".join(
                     "- Function {func} raised: {exc}\n"
                     for func, exc in zip(self.functions, exceptions)
@@ -165,25 +165,17 @@ def test_more_complicated_unions():
     ArgumentParser().add_arguments(MoreComplex, dest="unused")
     field_annotations = {f.name: f.type for f in dataclasses.fields(MoreComplex)}
     assert field_annotations["vals_list"] == typing.List[typing.Union[int, float]]
-    assert (
-        field_annotations["vals_tuple"] == typing.Tuple[typing.Union[int, float], bool]
-    )
+    assert field_annotations["vals_tuple"] == typing.Tuple[typing.Union[int, float], bool]
 
     assert is_list(field_annotations["vals_list"])
     assert is_tuple(field_annotations["vals_tuple"])
 
     assert MoreComplex.setup("--vals_list 456 123") == MoreComplex(vals_list=[456, 123])
-    assert MoreComplex.setup("--vals_list 4.56 1.23") == MoreComplex(
-        vals_list=[4.56, 1.23]
-    )
+    assert MoreComplex.setup("--vals_list 4.56 1.23") == MoreComplex(vals_list=[4.56, 1.23])
     # NOTE: Something funky is happening: Seems like the `float` type here is being registered as
     # the handler also in the second case, for the tuple!
-    assert MoreComplex.setup("--vals_tuple 456 False") == MoreComplex(
-        vals_tuple=(456, False)
-    )
-    assert MoreComplex.setup("--vals_tuple 4.56 True") == MoreComplex(
-        vals_tuple=(4.56, True)
-    )
+    assert MoreComplex.setup("--vals_tuple 456 False") == MoreComplex(vals_tuple=(456, False))
+    assert MoreComplex.setup("--vals_tuple 4.56 True") == MoreComplex(vals_tuple=(4.56, True))
 
 
 @pytest.mark.xfail(reason="TODO: Properly support containers of union types.")
@@ -200,6 +192,7 @@ def test_parsing_containers_of_unions():
 
 
 from dataclasses import dataclass
+
 from simple_parsing.helpers import Serializable
 
 
@@ -230,3 +223,23 @@ def test_serialization_deserialization():
     assert Wrapper.from_dict(opts.to_dict()) == opts
     assert Wrapper.loads_json(opts.dumps_json()) == opts
     assert Wrapper.loads_yaml(opts.dumps_yaml()) == opts
+
+
+@dataclass
+class OptimizerConfig(TestSetup):
+    lr_scheduler: str = "cosine"
+    """ LR scheduler to use. """
+
+
+@dataclass
+class SubclassOfOptimizerConfig(OptimizerConfig):
+    bar: int | float = 123
+    """ some dummy arg bar. """
+
+
+def test_missing_annotation_on_subclass():
+    """Test that the annotation are correctly fetched from the base class."""
+
+    assert SubclassOfOptimizerConfig.setup("--lr_scheduler cosine") == SubclassOfOptimizerConfig(
+        lr_scheduler="cosine"
+    )
diff --git a/test/test_issue64.py b/test/test_issue64.py
index aa8687f..9e79d22 100644
--- a/test/test_issue64.py
+++ b/test/test_issue64.py
@@ -1,11 +1,11 @@
+import textwrap
 from dataclasses import dataclass
-
-from simple_parsing import ArgumentParser
 from io import StringIO
-import textwrap
+from test.testutils import assert_help_output_equals
+
 import pytest
 
-from test.testutils import assert_help_output_equals
+from simple_parsing import ArgumentParser
 
 
 @dataclass
@@ -54,16 +54,10 @@ def test_vanilla_argparse_issue64():
     """
     import argparse
 
-    parser = ArgumentParser(
-        "issue64", formatter_class=argparse.ArgumentDefaultsHelpFormatter
-    )
+    parser = ArgumentParser("issue64", formatter_class=argparse.ArgumentDefaultsHelpFormatter)
 
-    group = parser.add_argument_group(
-        "Options ['options']", description="These are the options"
-    )
-    group.add_argument(
-        "--foo", type=str, metavar="str", default="aaa", help="Description"
-    )
+    group = parser.add_argument_group("Options ['options']", description="These are the options")
+    group.add_argument("--foo", type=str, metavar="str", default="aaa", help="Description")
     group.add_argument("--bar", type=str, metavar="str", default="bbb")
 
     from io import StringIO
diff --git a/test/test_subgroups.py b/test/test_subgroups.py
new file mode 100644
index 0000000..4a8fde8
--- /dev/null
+++ b/test/test_subgroups.py
@@ -0,0 +1,167 @@
+import contextlib
+from dataclasses import dataclass, is_dataclass
+from io import StringIO
+from typing import Union
+
+import pytest
+
+from simple_parsing import ArgumentParser, subgroups
+
+from .testutils import TestSetup, raises_missing_required_arg, raises_unrecognized_args
+
+
+@dataclass
+class Foo:
+    a: int = 1
+    b: int = 2
+
+
+@dataclass
+class Bar:
+    c: int = 1
+    d: int = 2
+
+
+@dataclass
+class Baz:
+    e: int = 1
+    f: bool = False
+
+
+@dataclass
+class Blop:
+    g: str = "arwg"
+    h: float = 1.2
+
+
+@dataclass
+class Bob(TestSetup):
+    thing: Union[Foo, Bar] = subgroups({"foo_thing": Foo, "bar_thing": Bar}, default=Bar(d=3))
+
+
+def test_remove_help_action():
+    # Test that it's possible to remove the '--help' action from a parser that had add_help=True
+
+    parser = ArgumentParser(add_help=True)
+    parser.add_arguments(Foo, "foo")
+    parser.add_arguments(Bar, "bar")
+    parser._remove_help_action()
+    import shlex
+
+    args, unused = parser.parse_known_args(shlex.split("--a 123 --c 456 --help"))
+    assert unused == ["--help"]
+    assert args.foo == Foo(a=123)
+    assert args.bar == Bar(c=456)
+
+
+class TestSubgroup:
+    def test_subgroup(self):
+        parser = ArgumentParser()
+        parser.add_arguments(Bob, dest="bob")
+        args = parser.parse_args("--thing foo_thing --thing.a 123".split())
+        bob = args.bob
+        thing = bob.thing
+        assert is_dataclass(thing)
+        assert thing == Foo(a=123)
+
+    def test_help_action(self):
+        """Test that the arguments for the chosen subgroup are shown in the help string."""
+
+        # The default is of type Bar, so if the option isn't passed, it should show the help for
+        # the default argument choice.
+        assert "--thing.c" in Bob.get_help_text("--help")
+
+        assert "--thing.a" in Bob.get_help_text("--thing foo_thing --help")
+        assert "--thing.c" in Bob.get_help_text("--thing bar_thing --help")
+
+    def test_manual_help_action(self):
+        """Test without the use of `get_help_text` from TestSetup, just to be sure."""
+        parser = ArgumentParser()
+        parser.add_arguments(Bob, dest="bob")
+
+        with StringIO() as f:
+            with contextlib.suppress(SystemExit), contextlib.redirect_stdout(f):
+                parser.parse_args(["--help"])
+            help_text = f.getvalue()
+        # print("\n" + help_text)
+        assert "--thing.c" in help_text
+
+    @pytest.mark.xfail(reason="TODO, doesn't quite work yet.")
+    def test_print_help(self):
+        parser = ArgumentParser()
+        parser.add_arguments(Bob, dest="bob")
+
+        with StringIO() as f:
+            parser.print_help(f)
+            help_text = f.getvalue()
+            print(help_text)
+            assert "--thing.c" in help_text
+
+
+def test_required_subgroup():
+    """Test when a subgroup doesn't have a default value, and is required."""
+
+    @dataclass
+    class Bob(TestSetup):
+        thing: Union[Foo, Bar] = subgroups({"foo": Foo, "bar": Bar})
+
+    with raises_missing_required_arg():
+        assert Bob.setup("")
+
+    assert Bob.setup("--thing foo") == Bob(thing=Foo())
+
+
+@dataclass
+class WithRequiredArg:
+    some_required_arg: int
+    other_arg: int = 123
+
+
+def test_subgroup_with_required_argument():
+    """Test where a subgroup has a required argument."""
+
+    @dataclass
+    class Bob(TestSetup):
+        thing: Union[Foo, WithRequiredArg] = subgroups({"foo": Foo, "req": WithRequiredArg})
+
+    assert Bob.setup("--thing foo --thing.a 44") == Bob(thing=Foo(a=44))
+    assert Bob.setup("--thing req --thing.some_required_arg 22") == Bob(
+        thing=WithRequiredArg(some_required_arg=22)
+    )
+    with raises_missing_required_arg():
+        assert Bob.setup("--thing req")
+
+
+def test_two_subgroups():
+    @dataclass
+    class Bob(TestSetup):
+        first: Union[Foo, Bar] = subgroups({"foo": Foo, "bar": Bar}, default=Bar(d=3))
+        second: Union[Baz, Blop] = subgroups({"baz": Baz, "blop": Blop}, default=Blop())
+
+    bob = Bob.setup("--first foo --first.a 123 --second blop --second.g arwg --second.h 1.2")
+    assert bob == Bob(first=Foo(a=123), second=Blop(g="arwg", h=1.2))
+
+
+def test_two_subgroups_with_conflict():
+    @dataclass
+    class Bob(TestSetup):
+        first: Union[Foo, Bar] = subgroups({"foo": Foo, "bar": Bar}, default=Bar(d=3))
+        second: Union[Foo, Blop] = subgroups({"foo": Foo, "blop": Blop}, default=Blop())
+
+    assert Bob.setup(
+        "--first foo --first.a 123 --second blop --second.g arwg --second.h 1.2"
+    ) == Bob(first=Foo(a=123), second=Blop(g="arwg", h=1.2))
+
+    assert Bob.setup("--first foo --first.a 123 --second foo --second.a 456") == Bob(
+        first=Foo(a=123), second=Foo(a=456)
+    )
+
+
+def test_unrelated_arg_raises_error():
+    @dataclass
+    class Bob(TestSetup):
+        first: Union[Foo, Bar] = subgroups({"foo": Foo, "bar": Bar}, default=Bar(d=3))
+        second: Union[Foo, Blop] = subgroups({"foo": Foo, "blop": Blop}, default=Blop())
+
+    with raises_unrecognized_args("--bblarga"):
+        Bob.setup("--first foo --first.a 123 --second foo --second.a 456 --bblarga")
diff --git a/test/testutils.py b/test/testutils.py
index 2be555d..3224950 100644
--- a/test/testutils.py
+++ b/test/testutils.py
@@ -2,34 +2,14 @@ import argparse
 import shlex
 import string
 import sys
-from contextlib import contextmanager, suppress
-from typing import (
-    Any,
-    Callable,
-    Dict,
-    Generic,
-    List,
-    Optional,
-    Tuple,
-    Type,
-    TypeVar,
-    cast,
-)
-import collections
+from contextlib import contextmanager
+from typing import Any, Callable, Generic, List, Optional, Tuple, Type, TypeVar, cast
 
 import pytest
 
 import simple_parsing
-from simple_parsing import (
-    ArgumentParser,
-    ConflictResolution,
-    InconsistentArgumentError,
-    ParsingError,
-    SimpleHelpFormatter,
-    DashVariant,
-)
+from simple_parsing import ConflictResolution, DashVariant, ParsingError
 from simple_parsing.utils import camel_case
-from simple_parsing.wrappers import DataclassWrapper
 from simple_parsing.wrappers.field_wrapper import ArgumentGenerationMode, NestedMode
 
 xfail = pytest.mark.xfail
@@ -82,7 +62,7 @@ def raises_expected_n_args(n: int):
 
 @contextmanager
 def raises_unrecognized_args(*args: str):
-    with exits_and_writes_to_stderr(f"unrecognized arguments: " + " ".join(args or [])):
+    with exits_and_writes_to_stderr("unrecognized arguments: " + " ".join(args or [])):
         yield
 
 
@@ -97,7 +77,6 @@ def assert_help_output_equals(actual: str, expected: str) -> None:
     if "optional arguments" in expected and sys.version_info[:2] >= (3, 10):
         expected = expected.replace("optional arguments", "options")
 
-    # assert actual == expected
     actual_str = "".join(actual.split())
     actual_str = actual.translate(str.maketrans("", "", remove))
     expected_str = expected.translate(str.maketrans("", "", remove))
@@ -146,7 +125,6 @@ class TestSetup:
         *,
         argument_generation_mode: ArgumentGenerationMode = ArgumentGenerationMode.FLAT,
         nested_mode: NestedMode = NestedMode.DEFAULT,
-
     ) -> Dataclass:
         """Basic setup for a test.
 
@@ -184,9 +162,7 @@ class TestSetup:
         assert hasattr(args, dest), f"attribute '{dest}' not found in args {args}"
         instance: Dataclass = getattr(args, dest)  # type: ignore
         delattr(args, dest)
-        assert (
-            args == argparse.Namespace()
-        ), f"Namespace has leftover garbage values: {args}"
+        assert args == argparse.Namespace(), f"Namespace has leftover garbage values: {args}"
 
         instance = cast(Dataclass, instance)
         return instance
@@ -197,9 +173,7 @@ class TestSetup:
     ) -> Tuple[Dataclass, ...]:
         conflict_resolution_mode: ConflictResolution = ConflictResolution.ALWAYS_MERGE
 
-        parser = simple_parsing.ArgumentParser(
-            conflict_resolution=conflict_resolution_mode
-        )
+        parser = simple_parsing.ArgumentParser(conflict_resolution=conflict_resolution_mode)
         class_name = camel_case(cls.__name__)
         for i in range(num_to_parse):
             parser.add_arguments(cls, f"{class_name}_{i}")
@@ -215,6 +189,7 @@ class TestSetup:
     @classmethod
     def get_help_text(
         cls,
+        argv: Optional[str] = None,
         multiple=False,
         conflict_resolution_mode: ConflictResolution = ConflictResolution.AUTO,
         add_option_string_dash_variants=DashVariant.AUTO,
@@ -223,9 +198,15 @@ class TestSetup:
         from io import StringIO
 
         f = StringIO()
+
+        if argv is None:
+            argv = "--help"
+        elif not argv.endswith("--help"):
+            argv = argv + " --help"
+
         with contextlib.suppress(SystemExit), contextlib.redirect_stdout(f):
             _ = cls.setup(
-                "--help",
+                argv,
                 conflict_resolution_mode=conflict_resolution_mode,
                 add_option_string_dash_variants=add_option_string_dash_variants,
             )
@@ -254,18 +235,12 @@ def format_list_using_double_quotes(value_list: List[Any]) -> str:
 
 
 def format_lists_using_brackets(list_of_lists: List[List[Any]]) -> str:
-    return " ".join(
-        format_list_using_brackets(value_list) for value_list in list_of_lists
-    )
+    return " ".join(format_list_using_brackets(value_list) for value_list in list_of_lists)
 
 
 def format_lists_using_double_quotes(list_of_lists: List[List[Any]]) -> str:
-    return " ".join(
-        format_list_using_double_quotes(value_list) for value_list in list_of_lists
-    )
+    return " ".join(format_list_using_double_quotes(value_list) for value_list in list_of_lists)
 
 
 def format_lists_using_single_quotes(list_of_lists: List[List[Any]]) -> str:
-    return " ".join(
-        format_list_using_single_quotes(value_list) for value_list in list_of_lists
-    )
+    return " ".join(format_list_using_single_quotes(value_list) for value_list in list_of_lists)