Repository: lebrice/SimpleParsing

Issue:
Add support for a config file with optional overriding 
**Is your feature request related to a problem? Please describe.**
I would like to be able to load the args configuration from a json/yaml file, and at the same time override some values from the command line

**Describe the solution you'd like**
e.g.
python my_script.py --config=conf.yaml --lr=1e-2

**Describe alternatives you've considered**
parsing the commandline argv for the overriding args - and manually set them - however this makes a lot it difficult - when dataclasses are nested



Discussion:

Patch:
diff --git a/README.md b/README.md
index 2190377..e84dc67 100644
--- a/README.md
+++ b/README.md
@@ -96,6 +96,10 @@ Options ['options']:
     It's easy to choose between different argument groups of arguments, with the `subgroups`
     function!
 
+- ### [Setting defaults from Configuration files](https://github.com/lebrice/SimpleParsing/tree/master/examples/config_files/README.md)
+
+    Default values for command-line arguments can easily be read from many different formats, including json/yaml!
+
 - ### [**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/config_files/README.md b/examples/config_files/README.md
new file mode 100644
index 0000000..1082981
--- /dev/null
+++ b/examples/config_files/README.md
@@ -0,0 +1,32 @@
+# Using config files
+
+Simple-Parsing can use default values from one or more configuration files.
+
+The `config_path` argument can be passed to the ArgumentParser constructor. The values read from
+that file will overwrite the default values from the dataclass definitions.
+
+Additionally, when the `add_config_path_arg` argument of the `ArgumentParser` constructor is set,
+a `--config_path` argument will be added to the parser. This argument accepts one or more paths to configuration
+files, whose contents will be read, and used to update the defaults, in the same manner as with the
+`config_path` argument above.
+
+When using both options (the `config_path` parameter of `ArgumentParser.__init__`, as well as the `--config_path` command-line argument), the defaults are first updated using `ArgumentParser.config_path`, and then
+updated with the contents of the `--config_path` file(s).
+
+In other words, the default values are set like so, in increasing priority:
+1. normal defaults (e.g. from the dataclass definitions)
+2. updated with the contents of the `config_path` file(s) of `ArgumentParser.__init__`
+3. updated with the contents of the `--config_path` file(s) from the command-line.
+
+
+## [Single Config example](one_config.py)
+
+When using a single config dataclass, the `simple_parsing.parse` function can then be used to simplify the argument parsing setup a bit.
+
+## [Multiple Configs](many_configs.py)
+
+Config files can also be used when defining multiple config dataclasses with the same parser.
+
+## [Composition](composition.py)
+
+Multiple config files can be composed together à-la Hydra!
diff --git a/examples/config_files/composition.py b/examples/config_files/composition.py
new file mode 100644
index 0000000..9ed16ff
--- /dev/null
+++ b/examples/config_files/composition.py
@@ -0,0 +1,65 @@
+""" Example where we compose different configurations! """
+
+import shlex
+from dataclasses import dataclass
+
+import simple_parsing
+
+
+@dataclass
+class Foo:
+    a: str = "default value for `a` (from the dataclass definition of Foo)"
+
+
+@dataclass
+class Bar:
+    b: str = "default value for `b` (from the dataclass definition of Bar)"
+
+
+@dataclass
+class Baz:
+    c: str = "default value for `c` (from the dataclass definition of Baz)"
+
+
+def main(args=None) -> None:
+    """Example using composition of different configurations."""
+    parser = simple_parsing.ArgumentParser(
+        add_config_path_arg=True, config_path="composition_defaults.yaml"
+    )
+
+    parser.add_arguments(Foo, dest="foo")
+    parser.add_arguments(Bar, dest="bar")
+    parser.add_arguments(Baz, dest="baz")
+
+    if isinstance(args, str):
+        args = shlex.split(args)
+    args = parser.parse_args(args)
+
+    foo: Foo = args.foo
+    bar: Bar = args.bar
+    baz: Baz = args.baz
+    print(f"foo: {foo}")
+    print(f"bar: {bar}")
+    print(f"baz: {baz}")
+
+
+main()
+expected = """
+foo: Foo(a="default value for `a` from the Parser's `config_path` (composition_defaults.yaml)")
+bar: Bar(b="default value for `b` from the Parser's `config_path` (composition_defaults.yaml)")
+baz: Baz(c="default value for `c` from the Parser's `config_path` (composition_defaults.yaml)")
+"""
+
+main("--a 'Value passed from the command-line.' --config_path config_b.yaml")
+expected += """\
+foo: Foo(a='Value passed from the command-line.')
+bar: Bar(b='default value for `b` from the config_b.yaml file')
+baz: Baz(c="default value for `c` from the Parser's `config_path` (composition_defaults.yaml)")
+"""
+
+main("--a 'Value passed from the command-line.' --config_path config_a.yaml config_b.yaml")
+expected += """\
+foo: Foo(a='Value passed from the command-line.')
+bar: Bar(b='default value for `b` from the config_b.yaml file')
+baz: Baz(c="default value for `c` from the Parser's `config_path` (composition_defaults.yaml)")
+"""
diff --git a/examples/config_files/composition_defaults.yaml b/examples/config_files/composition_defaults.yaml
new file mode 100644
index 0000000..481f83b
--- /dev/null
+++ b/examples/config_files/composition_defaults.yaml
@@ -0,0 +1,8 @@
+foo:
+  a: "default value for `a` from the Parser's `config_path` (composition_defaults.yaml)"
+
+bar:
+  b: "default value for `b` from the Parser's `config_path` (composition_defaults.yaml)"
+
+baz:
+  c: "default value for `c` from the Parser's `config_path` (composition_defaults.yaml)"
diff --git a/examples/config_files/config_a.yaml b/examples/config_files/config_a.yaml
new file mode 100644
index 0000000..494910b
--- /dev/null
+++ b/examples/config_files/config_a.yaml
@@ -0,0 +1,2 @@
+foo:
+  a: "default value for `a` from the config_a.yaml file"
diff --git a/examples/config_files/config_b.yaml b/examples/config_files/config_b.yaml
new file mode 100644
index 0000000..39cb3e7
--- /dev/null
+++ b/examples/config_files/config_b.yaml
@@ -0,0 +1,2 @@
+bar:
+  b: "default value for `b` from the config_b.yaml file"
diff --git a/examples/config_files/many_configs.py b/examples/config_files/many_configs.py
new file mode 100644
index 0000000..50e6147
--- /dev/null
+++ b/examples/config_files/many_configs.py
@@ -0,0 +1,56 @@
+import shlex
+from dataclasses import dataclass
+
+import simple_parsing
+
+
+@dataclass
+class TrainConfig:
+    """Training config for Machine Learning"""
+
+    workers: int = 8  # The number of workers for training
+    exp_name: str = "default_exp"  # The experiment name
+
+
+@dataclass
+class EvalConfig:
+    """Evaluation config."""
+
+    n_batches: int = 8  # The number of batches for evaluation
+    checkpoint: str = "best.pth"  # The checkpoint to use
+
+
+def main(args=None) -> None:
+    parser = simple_parsing.ArgumentParser(add_config_path_arg=True)
+
+    parser.add_arguments(TrainConfig, dest="train")
+    parser.add_arguments(EvalConfig, dest="eval")
+
+    if isinstance(args, str):
+        args = shlex.split(args)
+    args = parser.parse_args(args)
+
+    train_config: TrainConfig = args.train
+    eval_config: EvalConfig = args.eval
+    print(f"Training {train_config.exp_name} with {train_config.workers} workers...")
+    print(f"Evaluating '{eval_config.checkpoint}' with {eval_config.n_batches} batches...")
+
+
+main()
+expected = """
+Training default_exp with 8 workers...
+Evaluating 'best.pth' with 8 batches...
+"""
+
+
+main("")
+expected += """\
+Training default_exp with 8 workers...
+Evaluating 'best.pth' with 8 batches...
+"""
+
+main("--config_path many_configs.yaml --exp_name my_first_exp")
+expected += """\
+Training my_first_exp with 42 workers...
+Evaluating 'best.pth' with 100 batches...
+"""
diff --git a/examples/config_files/many_configs.yaml b/examples/config_files/many_configs.yaml
new file mode 100644
index 0000000..cfeca26
--- /dev/null
+++ b/examples/config_files/many_configs.yaml
@@ -0,0 +1,6 @@
+train:
+  exp_name: my_yaml_exp
+  workers: 42
+eval:
+  checkpoint: best.pth
+  n_batches: 100
diff --git a/examples/config_files/one_config.py b/examples/config_files/one_config.py
new file mode 100644
index 0000000..dffeba9
--- /dev/null
+++ b/examples/config_files/one_config.py
@@ -0,0 +1,34 @@
+""" Example adapted from https://github.com/eladrich/pyrallis#my-first-pyrallis-example- """
+from dataclasses import dataclass
+
+import simple_parsing
+
+
+@dataclass
+class TrainConfig:
+    """Training configuration."""
+
+    workers: int = 8  # The number of workers for training
+    exp_name: str = "default_exp"  # The experiment name
+
+
+def main(args=None) -> None:
+    cfg = simple_parsing.parse(config_class=TrainConfig, args=args)
+    print(f"Training {cfg.exp_name} with {cfg.workers} workers...")
+
+
+main()
+expected = """
+Training default_exp with 8 workers...
+"""
+
+main("")
+expected += """\
+Training default_exp with 8 workers...
+"""
+
+# NOTE: When running as in the readme:
+main("--config_path one_config.yaml --exp_name my_first_exp")
+expected += """\
+Training my_first_exp with 42 workers...
+"""
diff --git a/examples/config_files/one_config.yaml b/examples/config_files/one_config.yaml
new file mode 100644
index 0000000..5b255e0
--- /dev/null
+++ b/examples/config_files/one_config.yaml
@@ -0,0 +1,2 @@
+exp_name: my_yaml_exp
+workers: 42
diff --git a/examples/serialization/bob.yaml b/examples/serialization/bob.yaml
deleted file mode 100644
index 47c20c6..0000000
--- a/examples/serialization/bob.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-age: 20
-average_grade: 0.8
-domain: Computer Science
-name: Bob
diff --git a/examples/subparsers/optional_subparsers.py b/examples/subparsers/optional_subparsers.py
index b4e08e1..2e93160 100644
--- a/examples/subparsers/optional_subparsers.py
+++ b/examples/subparsers/optional_subparsers.py
@@ -40,5 +40,8 @@ def main():
     print(options)
 
 
-if __name__ == "__main__":
-    main()
+main()
+expected = """
+Namespace(options=Options(config=AConfig(foo=123)))
+Options(config=AConfig(foo=123))
+"""
diff --git a/simple_parsing/__init__.py b/simple_parsing/__init__.py
index 19b6705..f52740f 100644
--- a/simple_parsing/__init__.py
+++ b/simple_parsing/__init__.py
@@ -21,6 +21,7 @@ from .parsing import (
     DashVariant,
     NestedMode,
     ParsingError,
+    parse,
 )
 from .utils import InconsistentArgumentError
 
diff --git a/simple_parsing/helpers/serialization/__init__.py b/simple_parsing/helpers/serialization/__init__.py
index 713a5db..a766712 100644
--- a/simple_parsing/helpers/serialization/__init__.py
+++ b/simple_parsing/helpers/serialization/__init__.py
@@ -1,6 +1,24 @@
 from .decoding import *
 from .encoding import *
-from .serializable import FrozenSerializable, Serializable
+from .serializable import (
+    FrozenSerializable,
+    Serializable,
+    SerializableMixin,
+    dump,
+    dump_json,
+    dump_yaml,
+    dumps,
+    dumps_json,
+    dumps_yaml,
+    from_dict,
+    load,
+    load_json,
+    load_yaml,
+    save,
+    save_json,
+    save_yaml,
+    to_dict,
+)
 
 try:
     from .yaml_serialization import YamlSerializable
diff --git a/simple_parsing/helpers/serialization/decoding.py b/simple_parsing/helpers/serialization/decoding.py
index 9978813..2526491 100644
--- a/simple_parsing/helpers/serialization/decoding.py
+++ b/simple_parsing/helpers/serialization/decoding.py
@@ -8,6 +8,7 @@ from dataclasses import Field
 from enum import Enum
 from functools import lru_cache, partial
 from logging import getLogger
+from pathlib import Path
 from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type, TypeVar, Union
 
 from simple_parsing.annotation_utils.get_field_annotations import (
@@ -444,3 +445,6 @@ def try_constructor(t: Type[T]) -> Callable[[Any], Union[T, Any]]:
             return t(val)
 
     return try_functions(constructor)
+
+
+register_decoding_fn(Path, Path)
diff --git a/simple_parsing/helpers/serialization/serializable.py b/simple_parsing/helpers/serialization/serializable.py
index a770eac..339210d 100644
--- a/simple_parsing/helpers/serialization/serializable.py
+++ b/simple_parsing/helpers/serialization/serializable.py
@@ -1,21 +1,29 @@
+from __future__ import annotations
+
 import json
-import warnings
+import pickle
 from collections import OrderedDict
 from dataclasses import MISSING, Field, dataclass, fields, is_dataclass
+from functools import partial
 from itertools import chain
 from logging import getLogger
 from pathlib import Path
-from typing import IO, Any, ClassVar, Dict, List, Optional, Tuple, Type, TypeVar, Union
+from typing import IO, Any, Callable, ClassVar, TypeVar, Union
 
 from simple_parsing.utils import get_args, get_forward_arg, is_optional
 
 from .decoding import decode_field, register_decoding_fn
 from .encoding import SimpleJsonEncoder, encode
 
+DumpFn = Callable[[Any, IO], None]
+DumpsFn = Callable[[Any], str]
+LoadFn = Callable[[IO], dict]
+LoadsFn = Callable[[str], dict]
+
 logger = getLogger(__name__)
 
 Dataclass = TypeVar("Dataclass")
-D = TypeVar("D", bound="Serializable")
+D = TypeVar("D", bound="SerializableMixin")
 
 try:
     import yaml
@@ -64,10 +72,12 @@ class SerializableMixin:
     >>> assert config == config_
     """
 
-    subclasses: ClassVar[List[Type[D]]] = []
+    subclasses: ClassVar[list[type[D]]] = []
     decode_into_subclasses: ClassVar[bool] = False
 
-    def __init_subclass__(cls, decode_into_subclasses: bool = None, add_variants: bool = True):
+    def __init_subclass__(
+        cls, decode_into_subclasses: bool | None = None, add_variants: bool = True
+    ):
         logger.debug(f"Registering a new Serializable subclass: {cls}")
         super().__init_subclass__()
         if decode_into_subclasses is None:
@@ -92,7 +102,7 @@ class SerializableMixin:
         encode.register(cls, cls.to_dict)
         register_decoding_fn(cls, cls.from_dict)
 
-    def to_dict(self, dict_factory: Type[Dict] = dict, recurse: bool = True) -> Dict:
+    def to_dict(self, dict_factory: type[dict] = dict, recurse: bool = True) -> dict:
         """Serializes this dataclass to a dict.
 
         NOTE: This 'extends' the `asdict()` function from
@@ -100,42 +110,10 @@ class SerializableMixin:
         dict, or to perform some kind of custom encoding (for instance,
         detaching `Tensor` objects before serializing the dataclass to a dict).
         """
-        d: Dict[str, Any] = dict_factory()
-        for f in fields(self):
-            name = f.name
-            value = getattr(self, name)
-
-            # Do not include in dict if some corresponding flag was set in metadata.
-            include_in_dict = f.metadata.get("to_dict", True)
-            if not include_in_dict:
-                continue
-
-            custom_encoding_fn = f.metadata.get("encoding_fn")
-            if custom_encoding_fn:
-                # Use a custom encoding function if there is one.
-                d[name] = custom_encoding_fn(value)
-                continue
-
-            encoding_fn = encode
-            if isinstance(value, SerializableMixin) and recurse:
-                try:
-                    encoded = value.to_dict(dict_factory=dict_factory, recurse=recurse)
-                except TypeError:
-                    encoded = value.to_dict()
-                logger.debug(f"Encoded Serializable field {name}: {encoded}")
-            else:
-                try:
-                    encoded = encoding_fn(value)
-                except Exception as e:
-                    logger.error(
-                        f"Unable to encode value {value} of type {type(value)}! Leaving it as-is. (exception: {e})"
-                    )
-                    encoded = value
-            d[name] = encoded
-        return d
+        return to_dict(self, dict_factory=dict_factory, recurse=recurse)
 
     @classmethod
-    def from_dict(cls: Type[D], obj: Dict, drop_extra_fields: bool = None) -> D:
+    def from_dict(cls: type[D], obj: dict, drop_extra_fields: bool | None = None) -> D:
         """Parses an instance of `cls` from the given dict.
 
         NOTE: If the `decode_into_subclasses` class attribute is set to True (or
@@ -150,47 +128,32 @@ class SerializableMixin:
         of `cls` and drop the extra keys in the dict.
         Passing `drop_extra_fields=False` forces the above-mentioned behaviour.
         """
-        if drop_extra_fields is None:
-            drop_extra_fields = not cls.decode_into_subclasses
         return from_dict(cls, obj, drop_extra_fields=drop_extra_fields)
 
-    def dump(self, fp: IO[str], dump_fn=json.dump, **kwargs) -> None:
-        # Convert `self` into a dict.
-        d = self.to_dict()
-        # Serialize that dict to the file, using dump_fn.
-        dump_fn(d, fp, **kwargs)
-
-    def dump_json(self, fp: IO[str], dump_fn=json.dump, **kwargs) -> str:
-        return self.dump(fp, dump_fn=dump_fn, **kwargs)
+    def dump(self, fp: IO[str], dump_fn: DumpFn = json.dump) -> None:
+        dump(self, fp=fp, dump_fn=dump_fn)
 
-    def dump_yaml(self, fp: IO[str], dump_fn=None, **kwargs) -> str:
-        import yaml
+    def dump_json(self, fp: IO[str], dump_fn: DumpFn = json.dump, **kwargs) -> None:
+        return dump_json(self, fp, dump_fn=dump_fn, **kwargs)
 
-        if dump_fn is None:
-            dump_fn = yaml.dump
-        return self.dump(fp, dump_fn=dump_fn, **kwargs)
+    def dump_yaml(self, fp: IO[str], dump_fn: DumpFn | None = None, **kwargs) -> None:
+        return dump_yaml(self, fp, dump_fn=dump_fn, **kwargs)
 
-    def dumps(self, dump_fn=json.dumps, **kwargs) -> str:
-        d = self.to_dict()
-        return dump_fn(d, **kwargs)
+    def dumps(self, dump_fn: DumpsFn = json.dumps, **kwargs) -> str:
+        return dumps(self, dump_fn=dump_fn, **kwargs)
 
-    def dumps_json(self, dump_fn=json.dumps, **kwargs) -> str:
-        kwargs.setdefault("cls", SimpleJsonEncoder)
-        return self.dumps(dump_fn=dump_fn, **kwargs)
+    def dumps_json(self, dump_fn: DumpsFn = json.dumps, **kwargs) -> str:
+        return dumps_json(self, dump_fn=dump_fn, **kwargs)
 
-    def dumps_yaml(self, dump_fn=None, **kwargs) -> str:
-        import yaml
-
-        if dump_fn is None:
-            dump_fn = yaml.dump
-        return self.dumps(dump_fn=dump_fn, **kwargs)
+    def dumps_yaml(self, dump_fn: DumpsFn | None = None, **kwargs) -> str:
+        return dumps_yaml(self, dump_fn=dump_fn, **kwargs)
 
     @classmethod
     def load(
-        cls: Type[D],
-        path: Union[Path, str, IO[str]],
-        drop_extra_fields: bool = None,
-        load_fn=None,
+        cls: type[D],
+        path: Path | str | IO[str],
+        drop_extra_fields: bool | None = None,
+        load_fn: LoadFn | None = None,
         **kwargs,
     ) -> D:
         """Loads an instance of `cls` from the given file.
@@ -203,7 +166,7 @@ class SerializableMixin:
                 fields. Defaults to None, in which case we use the value of
                 `cls.decode_into_subclasses`.
                 For more info, see `cls.from_dict`.
-            load_fn ([type], optional): Which loading function to use. Defaults
+            load_fn (Callable, optional): Which loading function to use. Defaults
                 to None, in which case we try to use the appropriate loading
                 function depending on `path.suffix`:
                 {
@@ -220,64 +183,24 @@ class SerializableMixin:
         Returns:
             D: An instance of `cls`.
         """
-        if isinstance(path, str):
-            path = Path(path)
-
-        if load_fn is None and isinstance(path, Path):
-            if path.name.endswith((".yml", ".yaml")):
-                return cls.load_yaml(path, drop_extra_fields=drop_extra_fields, **kwargs)
-            elif path.name.endswith(".json"):
-                return cls.load_json(path, drop_extra_fields=drop_extra_fields, **kwargs)
-            elif path.name.endswith(".pth"):
-                import torch
-
-                load_fn = torch.loads
-            elif path.name.endswith(".npy"):
-                import numpy as np
-
-                load_fn = np.load
-            elif path.name.endswith(".pkl"):
-                import pickle
-
-                load_fn = pickle.load
-            warnings.warn(
-                RuntimeWarning(
-                    f"Not sure how to deserialize contents of {path} to a dict, as no "
-                    f" load_fn was passed explicitly. Will try to use {load_fn} as the "
-                    f"load function, based on the path name."
-                )
-            )
-
-        if load_fn is None:
-            raise RuntimeError(
-                f"Unable to determine what function to use in order to load "
-                f"path {path} into a dictionary, since no load_fn was passed, "
-                f"and the path doesn't have an unfamiliar extension.."
-            )
-
-        if isinstance(path, Path):
-            path = path.open()
-        return cls._load(path, load_fn=load_fn, drop_extra_fields=drop_extra_fields, **kwargs)
+        return load(cls, path=path, drop_extra_fields=drop_extra_fields, load_fn=load_fn, **kwargs)
 
     @classmethod
     def _load(
-        cls: Type[D],
+        cls: type[D],
         fp: IO[str],
-        drop_extra_fields: bool = None,
-        load_fn=json.load,
+        drop_extra_fields: bool | None = None,
+        load_fn: LoadFn = json.load,
         **kwargs,
     ) -> D:
-        # Load a dict from the file.
-        d = load_fn(fp, **kwargs)
-        # Convert the dict into an instance of the class.
-        return cls.from_dict(d, drop_extra_fields=drop_extra_fields)
+        return load(cls, path=fp, drop_extra_fields=drop_extra_fields, load_fn=load_fn, **kwargs)
 
     @classmethod
     def load_json(
-        cls: Type[D],
-        path: Union[str, Path],
-        drop_extra_fields: bool = None,
-        load_fn=json.load,
+        cls: type[D],
+        path: str | Path,
+        drop_extra_fields: bool | None = None,
+        load_fn: LoadFn = json.load,
         **kwargs,
     ) -> D:
         """Loads an instance from the corresponding json-formatted file.
@@ -290,13 +213,13 @@ class SerializableMixin:
         Returns:
             D: an instance of the dataclass.
         """
-        return cls.load(path, drop_extra_fields=drop_extra_fields, load_fn=load_fn, **kwargs)
+        return load_json(cls, path, drop_extra_fields=drop_extra_fields, load_fn=load_fn, **kwargs)
 
     @classmethod
     def load_yaml(
-        cls: Type[D],
-        path: Union[str, Path],
-        drop_extra_fields: bool = None,
+        cls: type[D],
+        path: str | Path,
+        drop_extra_fields: bool | None = None,
         load_fn=None,
         **kwargs,
     ) -> D:
@@ -311,97 +234,50 @@ class SerializableMixin:
         Returns:
             D: an instance of the dataclass.
         """
-        import yaml
-
-        if load_fn is None:
-            load_fn = yaml.safe_load
-        return cls.load(path, load_fn=load_fn, drop_extra_fields=drop_extra_fields, **kwargs)
-
-    def save(self, path: Union[str, Path], dump_fn=None, **kwargs) -> None:
-        if not isinstance(path, Path):
-            path = Path(path)
-
-        if dump_fn is None and isinstance(path, Path):
-            if path.name.endswith((".yml", ".yaml")):
-                return self.save_yaml(path, **kwargs)
-            elif path.name.endswith(".json"):
-                return self.save_json(path, **kwargs)
-            elif path.name.endswith(".pth"):
-                import torch
-
-                dump_fn = torch.save
-            elif path.name.endswith(".npy"):
-                import numpy as np
-
-                dump_fn = np.save
-            elif path.name.endswith(".pkl"):
-                import pickle
-
-                dump_fn = pickle.dump
-            warnings.warn(
-                RuntimeWarning(
-                    f"Not 100% sure how to deserialize contents of {path} to a "
-                    f"file as no dump_fn was passed explicitly. Will try to use "
-                    f"{dump_fn} as the serialization function, based on the path "
-                    f"suffix. ({path.suffix})"
-                )
-            )
-
-        if dump_fn is None:
-            raise RuntimeError(
-                f"Unable to determine what function to use in order to dump "
-                f"path {path} into a dictionary, since no dump_fn was passed, "
-                f"and the path doesn't have an unfamiliar extension: "
-                f"({path.suffix})"
-            )
-        self._save(path, dump_fn=dump_fn, **kwargs)
+        return load_yaml(cls, path, load_fn=load_fn, drop_extra_fields=drop_extra_fields, **kwargs)
 
-    def _save(self, path: Union[str, Path], dump_fn=json.dump, **kwargs) -> None:
-        d = self.to_dict()
-        logger.debug(f"saving to path {path}")
-        with open(path, "w") as fp:
-            dump_fn(d, fp, **kwargs)
+    def save(self, path: str | Path, dump_fn=None) -> None:
+        save(self, path=path, dump_fn=dump_fn)
 
-    def save_yaml(self, path: Union[str, Path], dump_fn=None, **kwargs) -> None:
-        import yaml
+    def _save(self, path: str | Path, dump_fn: DumpFn = json.dump, **kwargs) -> None:
+        save(self, path=path, dump_fn=partial(dump_fn, **kwargs))
 
-        if dump_fn is None:
-            dump_fn = yaml.dump
-        self.save(path, dump_fn=dump_fn, **kwargs)
+    def save_yaml(self, path: str | Path, dump_fn: DumpFn | None = None, **kwargs) -> None:
+        save_yaml(self, path, dump_fn=dump_fn, **kwargs)
 
-    def save_json(self, path: Union[str, Path], dump_fn=json.dump, **kwargs) -> None:
-        self.save(path, dump_fn=dump_fn, **kwargs)
+    def save_json(self, path: str | Path, dump_fn=json.dump, **kwargs) -> None:
+        save_json(self, path, dump_fn=dump_fn, **kwargs)
 
     @classmethod
     def loads(
-        cls: Type[D],
+        cls: type[D],
         s: str,
-        drop_extra_fields: bool = None,
-        load_fn=json.loads,
-        **kwargs,
+        drop_extra_fields: bool | None = None,
+        load_fn: LoadsFn = json.loads,
     ) -> D:
-        d = load_fn(s, **kwargs)
-        return cls.from_dict(d, drop_extra_fields=drop_extra_fields)
+        return loads(cls, s, drop_extra_fields=drop_extra_fields, load_fn=load_fn)
 
     @classmethod
     def loads_json(
-        cls: Type[D],
+        cls: type[D],
         s: str,
-        drop_extra_fields: bool = None,
+        drop_extra_fields: bool | None = None,
         load_fn=json.loads,
         **kwargs,
     ) -> D:
-        return cls.loads(s, drop_extra_fields=drop_extra_fields, load_fn=load_fn, **kwargs)
+        return loads_json(
+            cls, s, drop_extra_fields=drop_extra_fields, load_fn=partial(load_fn, **kwargs)
+        )
 
     @classmethod
     def loads_yaml(
-        cls: Type[D], s: str, drop_extra_fields: bool = None, load_fn=None, **kwargs
+        cls: type[D],
+        s: str,
+        drop_extra_fields: bool | None = None,
+        load_fn: LoadsFn | None = None,
+        **kwargs,
     ) -> D:
-        import yaml
-
-        if load_fn is None:
-            load_fn = yaml.safe_load
-        return cls.loads(s, drop_extra_fields=drop_extra_fields, load_fn=load_fn, **kwargs)
+        return loads_yaml(cls, s, drop_extra_fields=drop_extra_fields, load_fn=load_fn, **kwargs)
 
 
 @dataclass
@@ -463,17 +339,342 @@ S = TypeVar("S", bound=SerializableMixin)
 
 
 def get_dataclass_types_from_forward_ref(
-    forward_ref: Type, serializable_base_class: Type[S] = SerializableMixin
-) -> List[Type[S]]:
+    forward_ref: type, serializable_base_class: type[S] = SerializableMixin
+) -> list[type[S]]:
     arg = get_forward_arg(forward_ref)
-    potential_classes: List[Type] = []
+    potential_classes: list[type] = []
     for serializable_class in serializable_base_class.subclasses:
         if serializable_class.__name__ == arg:
             potential_classes.append(serializable_class)
     return potential_classes
 
 
-def from_dict(cls: Type[Dataclass], d: Dict[str, Any], drop_extra_fields: bool = None) -> Dataclass:
+T = TypeVar("T")
+
+
+def load(
+    cls: type[Dataclass],
+    path: Path | str | IO,
+    drop_extra_fields: bool | None = None,
+    load_fn: LoadFn | None = None,
+) -> Dataclass:
+    """Loads an instance of `cls` from the given file.
+
+    Args:
+        cls (Type[D]): A dataclass type to load.
+        path (Path | str): Path or Path string or open file.
+        drop_extra_fields (bool, optional): Whether to drop extra fields or
+            to decode the dictionary into the first subclass with matching
+            fields. Defaults to None, in which case we use the value of
+            `cls.decode_into_subclasses`.
+            For more info, see `cls.from_dict`.
+        load_fn ([type], optional): Which loading function to use. Defaults
+            to None, in which case we try to use the appropriate loading
+            function depending on `path.suffix`:
+            {
+                ".yml": yaml.safe_load,
+                ".yaml": yaml.safe_load,
+                ".json": json.load,
+                ".pth": torch.load,
+                ".pkl": pickle.load,
+            }
+
+    Raises:
+        RuntimeError: If the extension of `path` is unsupported.
+
+    Returns:
+        D: An instance of `cls`.
+    """
+    if isinstance(path, str):
+        path = Path(path)
+    if load_fn is None and isinstance(path, Path):
+        # Load a dict from the file.
+        d = read_file(path)
+    elif load_fn:
+        with (path.open() if isinstance(path, Path) else path) as f:
+            d = load_fn(f)
+    else:
+        raise ValueError(
+            "A loading function must be passed, since we got an io stream, and the "
+            "extension can't be retrieved."
+        )
+    # Convert the dict into an instance of the class.
+    if drop_extra_fields is None and getattr(cls, "decode_into_subclasses", None) is not None:
+        drop_extra_fields = not getattr(cls, "decode_into_subclasses")
+    return from_dict(cls, d, drop_extra_fields=drop_extra_fields)
+
+
+def load_json(
+    cls: type[Dataclass],
+    path: str | Path,
+    drop_extra_fields: bool | None = None,
+    load_fn: LoadFn = json.load,
+    **kwargs,
+) -> Dataclass:
+    """Loads an instance from the corresponding json-formatted file.
+
+    Args:
+        cls (Type[D]): A dataclass type to load.
+        path (Union[str, Path]): Path to a json-formatted file.
+        load_fn ([type], optional): Loading function to use. Defaults to json.load.
+
+    Returns:
+        D: an instance of the dataclass.
+    """
+    return load(cls, path, drop_extra_fields=drop_extra_fields, load_fn=partial(load_fn, **kwargs))
+
+
+def loads(
+    cls: type[Dataclass],
+    s: str,
+    drop_extra_fields: bool | None = None,
+    load_fn: LoadsFn = json.loads,
+) -> Dataclass:
+    d = load_fn(s)
+    return from_dict(cls, d, drop_extra_fields=drop_extra_fields)
+
+
+def loads_json(
+    cls: type[Dataclass],
+    s: str,
+    drop_extra_fields: bool | None = None,
+    load_fn: LoadsFn = json.loads,
+    **kwargs,
+) -> Dataclass:
+    return loads(cls, s, drop_extra_fields=drop_extra_fields, load_fn=partial(load_fn, **kwargs))
+
+
+def loads_yaml(
+    cls: type[Dataclass],
+    s: str,
+    drop_extra_fields: bool | None = None,
+    load_fn: LoadsFn | None = None,
+    **kwargs,
+) -> Dataclass:
+    import yaml
+
+    load_fn = load_fn or yaml.safe_load
+    return loads(cls, s, drop_extra_fields=drop_extra_fields, load_fn=partial(load_fn, **kwargs))
+
+
+extensions_to_loading_fn: dict[str, Callable[[IO], Any]] = {
+    ".json": json.load,
+    ".pkl": pickle.load,
+}
+extensions_to_read_mode: dict[str, str] = {}
+
+extensions_to_write_mode: dict[str, str] = {}
+extensions_to_dump_fn: dict[str, Callable[[Any, IO], None]] = {
+    ".json": json.dump,
+    ".pkl": pickle.dump,
+}
+try:
+    import yaml
+
+    extensions_to_loading_fn[".yaml"] = yaml.safe_load
+    extensions_to_loading_fn[".yml"] = yaml.safe_load
+    extensions_to_dump_fn[".yaml"] = yaml.dump
+    extensions_to_dump_fn[".yml"] = yaml.dump
+
+
+except ImportError:
+    pass
+
+try:
+    import numpy  # type: ignore
+
+    extensions_to_loading_fn[".npy"] = numpy.load
+    extensions_to_dump_fn[".npy"] = numpy.save
+    extensions_to_read_mode[".npy"] = "rb"
+    extensions_to_write_mode[".npy"] = "wb"
+
+except ImportError:
+    pass
+
+try:
+    import torch  # type: ignore
+
+    extensions_to_loading_fn[".pth"] = torch.load
+    extensions_to_dump_fn[".pth"] = torch.save
+    extensions_to_read_mode[".pth"] = "rb"
+    extensions_to_write_mode[".pth"] = "wb"
+except ImportError:
+    pass
+
+
+def read_file(path: str | Path) -> dict:
+    """Returns the contents of the given file as a dictionary.
+    Uses the right function depending on `path.suffix`:
+    {
+        ".yml": yaml.safe_load,
+        ".yaml": yaml.safe_load,
+        ".json": json.load,
+        ".pth": torch.load,
+        ".pkl": pickle.load,
+    }
+    """
+    path = Path(path)
+    if path.suffix in extensions_to_loading_fn:
+        load_fn = extensions_to_loading_fn[path.suffix]
+    else:
+        raise RuntimeError(
+            f"Unable to determine what function to use in order to load "
+            f"path {path} into a dictionary since the path's extension isn't registered in the "
+            f"`extensions_to_loading_fn` dictionary..."
+        )
+    mode = extensions_to_read_mode.get(path.suffix, "r")
+    with open(path, mode=mode) as f:
+        return load_fn(f)
+
+
+def save(obj: Any, path: str | Path, dump_fn: Callable[[dict, IO], None] | None = None) -> None:
+    """Save the given dataclass or dictionary to the given file."""
+    path = Path(path)
+
+    if not isinstance(obj, dict):
+        obj = to_dict(obj)
+
+    if dump_fn:
+        save_fn = dump_fn
+    elif path.suffix in extensions_to_dump_fn:
+        save_fn = extensions_to_dump_fn[path.suffix]
+    else:
+        raise RuntimeError(
+            f"Unable to determine what function to use in order to save obj {obj} to path {path},"
+            f"since the path's extension isn't registered in the "
+            f"`extensions_to_dump_fn` dictionary..."
+        )
+    mode = extensions_to_write_mode.get(path.suffix, "w")
+    with open(path, mode=mode) as f:
+        return save_fn(obj, f)
+
+
+def save_yaml(obj, path: str | Path, dump_fn: DumpFn | None = None, **kwargs) -> None:
+    import yaml
+
+    if dump_fn is None:
+        dump_fn = yaml.dump
+    save(obj, path, dump_fn=partial(dump_fn, **kwargs))
+
+
+def save_json(obj, path: str | Path, dump_fn: DumpFn = json.dump, **kwargs) -> None:
+    save(obj, path, dump_fn=partial(dump_fn, **kwargs))
+
+
+def load_yaml(
+    cls: type[T],
+    path: str | Path,
+    drop_extra_fields: bool | None = None,
+    load_fn: LoadFn | None = None,
+    **kwargs,
+) -> T:
+    """Loads an instance from the corresponding yaml-formatted file.
+
+    Args:
+        cls (Type[T]): A dataclass type to load.
+        path (Union[str, Path]): Path to a yaml-formatted file.
+        load_fn ([type], optional): Loading function to use. Defaults to
+            None, in which case `yaml.safe_load` is used.
+
+    Returns:
+        T: an instance of the dataclass.
+    """
+    import yaml
+
+    if load_fn is None:
+        load_fn = yaml.safe_load
+    return load(cls, path, drop_extra_fields=drop_extra_fields, load_fn=partial(load_fn, **kwargs))
+
+
+def dump(dc, fp: IO[str], dump_fn: DumpFn = json.dump) -> None:
+    # Convert `dc` into a dict if needed.
+    if not isinstance(dc, dict):
+        dc = to_dict(dc)
+    # Serialize that dict to the file using dump_fn.
+    dump_fn(dc, fp)
+
+
+def dump_json(dc, fp: IO[str], dump_fn: DumpFn = json.dump, **kwargs) -> None:
+    return dump(dc, fp, dump_fn=partial(dump_fn, **kwargs))
+
+
+def dump_yaml(dc, fp: IO[str], dump_fn: DumpFn | None = None, **kwargs) -> None:
+    import yaml
+
+    if dump_fn is None:
+        dump_fn = yaml.dump
+    return dump(dc, fp, dump_fn=partial(dump_fn, **kwargs))
+
+
+def dumps(dc, dump_fn: DumpsFn = json.dumps) -> str:
+    if not isinstance(dc, dict):
+        dc = to_dict(dc)
+    return dump_fn(dc)
+
+
+def dumps_json(dc, dump_fn: DumpsFn = json.dumps, **kwargs) -> str:
+    kwargs.setdefault("cls", SimpleJsonEncoder)
+    return dumps(dc, dump_fn=partial(dump_fn, **kwargs))
+
+
+def dumps_yaml(dc, dump_fn: DumpsFn | None = None, **kwargs) -> str:
+    import yaml
+
+    if dump_fn is None:
+        dump_fn = yaml.dump
+    return dumps(dc, dump_fn=partial(dump_fn, **kwargs))
+
+
+def to_dict(dc, dict_factory: type[dict] = dict, recurse: bool = True) -> dict:
+    """Serializes this dataclass to a dict.
+
+    NOTE: This 'extends' the `asdict()` function from
+    the `dataclasses` package, allowing us to not include some fields in the
+    dict, or to perform some kind of custom encoding (for instance,
+    detaching `Tensor` objects before serializing the dataclass to a dict).
+    """
+    if not is_dataclass(dc):
+        raise ValueError("to_dict should only be called on a dataclass instance.")
+
+    d: dict[str, Any] = dict_factory()
+    for f in fields(dc):
+        name = f.name
+        value = getattr(dc, name)
+
+        # Do not include in dict if some corresponding flag was set in metadata.
+        include_in_dict = f.metadata.get("to_dict", True)
+        if not include_in_dict:
+            continue
+
+        custom_encoding_fn = f.metadata.get("encoding_fn")
+        if custom_encoding_fn:
+            # Use a custom encoding function if there is one.
+            d[name] = custom_encoding_fn(value)
+            continue
+
+        encoding_fn = encode
+        # TODO: Make a variant of the serialization tests that use the static functions everywhere.
+        if is_dataclass(value) and recurse:
+            try:
+                encoded = to_dict(value, dict_factory=dict_factory, recurse=recurse)
+            except TypeError:
+                encoded = to_dict(value)
+            logger.debug(f"Encoded dataclass field {name}: {encoded}")
+        else:
+            try:
+                encoded = encoding_fn(value)
+            except Exception as e:
+                logger.error(
+                    f"Unable to encode value {value} of type {type(value)}! Leaving it as-is. (exception: {e})"
+                )
+                encoded = value
+        d[name] = encoded
+    return d
+
+
+def from_dict(
+    cls: type[Dataclass], d: dict[str, Any], drop_extra_fields: bool | None = None
+) -> Dataclass:
     """Parses an instance of the dataclass `cls` from the dict `d`.
 
     Args:
@@ -501,10 +702,11 @@ def from_dict(cls: Type[Dataclass], d: Dict[str, Any], drop_extra_fields: bool =
     """
     if d is None:
         return None
-    obj_dict: Dict[str, Any] = d.copy()
 
-    init_args: Dict[str, Any] = {}
-    non_init_args: Dict[str, Any] = {}
+    obj_dict: dict[str, Any] = d.copy()
+
+    init_args: dict[str, Any] = {}
+    non_init_args: dict[str, Any] = {}
 
     if drop_extra_fields is None:
         drop_extra_fields = not getattr(cls, "decode_into_subclasses", False)
@@ -554,7 +756,7 @@ def from_dict(cls: Type[Dataclass], d: Dict[str, Any], drop_extra_fields: bool =
             logger.debug(f"Missing field names: {extra_args.keys()}")
 
             # Find all the "registered" subclasses of `cls`. (from Serializable)
-            derived_classes: List[Type[SerializableMixin]] = []
+            derived_classes: list[type[SerializableMixin]] = []
             for subclass in cls.subclasses:
                 if issubclass(subclass, cls) and subclass is not cls:
                     derived_classes.append(subclass)
@@ -570,7 +772,7 @@ def from_dict(cls: Type[Dataclass], d: Dict[str, Any], drop_extra_fields: bool =
 
             for child_class in derived_classes:
                 logger.debug(f"child class: {child_class.__name__}, mro: {child_class.mro()}")
-                child_init_fields: Dict[str, Field] = get_init_fields(child_class)
+                child_init_fields: dict[str, Field] = get_init_fields(child_class)
                 child_init_field_names = set(child_init_fields.keys())
 
                 if child_init_field_names >= req_init_field_names:
@@ -593,15 +795,15 @@ def from_dict(cls: Type[Dataclass], d: Dict[str, Any], drop_extra_fields: bool =
     return instance
 
 
-def get_init_fields(dataclass: Type) -> Dict[str, Field]:
-    result: Dict[str, Field] = {}
+def get_init_fields(dataclass: type) -> dict[str, Field]:
+    result: dict[str, Field] = {}
     for field in fields(dataclass):
         if field.init:
             result[field.name] = field
     return result
 
 
-def get_first_non_None_type(optional_type: Union[Type, Tuple[Type, ...]]) -> Optional[Type]:
+def get_first_non_None_type(optional_type: type | tuple[type, ...]) -> type | None:
     if not isinstance(optional_type, tuple):
         optional_type = get_args(optional_type)
     for arg in optional_type:
@@ -612,6 +814,6 @@ def get_first_non_None_type(optional_type: Union[Type, Tuple[Type, ...]]) -> Opt
     return None
 
 
-def is_dataclass_or_optional_dataclass_type(t: Type) -> bool:
+def is_dataclass_or_optional_dataclass_type(t: type) -> bool:
     """Returns whether `t` is a dataclass type or an Optional[<dataclass type>]."""
     return is_dataclass(t) or (is_optional(t) and is_dataclass(get_args(t)[0]))
diff --git a/simple_parsing/helpers/serialization/yaml_serialization.py b/simple_parsing/helpers/serialization/yaml_serialization.py
index 1ccea33..7091c5e 100644
--- a/simple_parsing/helpers/serialization/yaml_serialization.py
+++ b/simple_parsing/helpers/serialization/yaml_serialization.py
@@ -1,6 +1,8 @@
+from __future__ import annotations
+
 from logging import getLogger
 from pathlib import Path
-from typing import IO, Type, Union
+from typing import IO
 
 import yaml
 
@@ -26,22 +28,30 @@ class YamlSerializable(Serializable):
 
     @classmethod
     def load(
-        cls: Type[D],
-        path: Union[Path, str, IO[str]],
-        drop_extra_fields: bool = None,
+        cls: type[D],
+        path: Path | str | IO[str],
+        drop_extra_fields: bool | None = None,
         load_fn=yaml.safe_load,
-        **kwargs
+        **kwargs,
     ) -> D:
         return super().load(path, drop_extra_fields=drop_extra_fields, load_fn=load_fn, **kwargs)
 
     @classmethod
     def loads(
-        cls: Type[D], s: str, drop_extra_fields: bool = None, load_fn=yaml.safe_load, **kwargs
+        cls: type[D],
+        s: str,
+        drop_extra_fields: bool | None = None,
+        load_fn=yaml.safe_load,
+        **kwargs,
     ) -> D:
         return super().loads(s, drop_extra_fields=drop_extra_fields, load_fn=load_fn, **kwargs)
 
     @classmethod
     def _load(
-        cls: Type[D], fp: IO[str], drop_extra_fields: bool = None, load_fn=yaml.safe_load, **kwargs
+        cls: type[D],
+        fp: IO[str],
+        drop_extra_fields: bool | None = None,
+        load_fn=yaml.safe_load,
+        **kwargs,
     ) -> D:
         return super()._load(fp, drop_extra_fields=drop_extra_fields, load_fn=load_fn, **kwargs)
diff --git a/simple_parsing/parsing.py b/simple_parsing/parsing.py
index d98511b..9d4187d 100644
--- a/simple_parsing/parsing.py
+++ b/simple_parsing/parsing.py
@@ -5,17 +5,20 @@ from __future__ import annotations
 
 import argparse
 import dataclasses
+import shlex
 import sys
 from argparse import SUPPRESS, Action, HelpFormatter, Namespace, _, _HelpAction
 from collections import defaultdict
 from functools import partial
 from logging import getLogger
+from pathlib import Path
 from typing import Any, Callable, Sequence, TypeVar, overload
 
 from . import utils
 from .conflicts import ConflictResolution, ConflictResolver
 from .help_formatter import SimpleHelpFormatter
-from .utils import Dataclass
+from .helpers.serialization.serializable import read_file
+from .utils import Dataclass, dict_union
 from .wrappers import DashVariant, DataclassWrapper, FieldWrapper
 from .wrappers.field_wrapper import ArgumentGenerationMode, NestedMode
 
@@ -27,73 +30,81 @@ class ParsingError(RuntimeError, SystemExit):
 
 
 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,
-        nested_mode: NestedMode = NestedMode.DEFAULT,
-        formatter_class: type[HelpFormatter] = SimpleHelpFormatter,
-        **kwargs,
-    ):
-        """Creates an ArgumentParser instance.
+    """Creates an ArgumentParser instance.
 
-        Parameters
-        ----------
-        - conflict_resolution : ConflictResolution, optional
+    Parameters
+    ----------
+    - conflict_resolution : ConflictResolution, optional
 
-            What kind of prefixing mechanism to use when reusing dataclasses
-            (argument groups).
-            For more info, check the docstring of the `ConflictResolution` Enum.
+        What kind of prefixing mechanism to use when reusing dataclasses
+        (argument groups).
+        For more info, check the docstring of the `ConflictResolution` Enum.
 
-        - add_option_string_dash_variants : DashVariant, optional
+    - add_option_string_dash_variants : DashVariant, optional
 
-            Whether or not to add option_string variants where the underscores in
-            attribute names are replaced with dashes.
-            For example, when set to DashVariant.UNDERSCORE_AND_DASH,
-            "--no-cache" and "--no_cache" can both be used to point to the same
-            attribute `no_cache` on some dataclass.
+        Whether or not to add option_string variants where the underscores in
+        attribute names are replaced with dashes.
+        For example, when set to DashVariant.UNDERSCORE_AND_DASH,
+        "--no-cache" and "--no_cache" can both be used to point to the same
+        attribute `no_cache` on some dataclass.
 
-        - argument_generation_mode : ArgumentGenerationMode, optional
+    - argument_generation_mode : ArgumentGenerationMode, optional
 
-            How to generate the arguments. In the ArgumentGenerationMode.FLAT mode,
-            the default one, the arguments are flat when possible, ignoring
-            their nested structure and including it only on the presence of a
-            conflict.
+        How to generate the arguments. In the ArgumentGenerationMode.FLAT mode,
+        the default one, the arguments are flat when possible, ignoring
+        their nested structure and including it only on the presence of a
+        conflict.
 
-            In the ArgumentGenerationMode.NESTED mode, the arguments are always
-            composed reflecting their nested structure.
+        In the ArgumentGenerationMode.NESTED mode, the arguments are always
+        composed reflecting their nested structure.
 
-            In the ArgumentGenerationMode.BOTH mode, both kind of arguments
-            are generated.
+        In the ArgumentGenerationMode.BOTH mode, both kind of arguments
+        are generated.
 
-        - nested_mode : NestedMode, optional
+    - nested_mode : NestedMode, optional
 
-            How to handle argument generation in for nested arguments
-            in the modes ArgumentGenerationMode.NESTED and ArgumentGenerationMode.BOTH.
-            In the NestedMode.DEFAULT mode, the nested arguments are generated
-            reflecting their full 'destination' path from the returning namespace.
+        How to handle argument generation in for nested arguments
+        in the modes ArgumentGenerationMode.NESTED and ArgumentGenerationMode.BOTH.
+        In the NestedMode.DEFAULT mode, the nested arguments are generated
+        reflecting their full 'destination' path from the returning namespace.
 
-            In the NestedMode.WITHOUT_ROOT, the first level is removed. This is useful when
-            parser.add_arguments is only called once, and where the same prefix would be shared
-            by all arguments. For example, if you have a single dataclass MyArguments and
-            you call parser.add_arguments(MyArguments, "args"), the arguments could look like this:
-            '--args.input.path --args.output.path'.
-            We could prefer to remove the root level in such a case
-             so that the arguments get generated as
-            '--input.path --output.path'.
+        In the NestedMode.WITHOUT_ROOT, the first level is removed. This is useful when
+        parser.add_arguments is only called once, and where the same prefix would be shared
+        by all arguments. For example, if you have a single dataclass MyArguments and
+        you call parser.add_arguments(MyArguments, "args"), the arguments could look like this:
+        '--args.input.path --args.output.path'.
+        We could prefer to remove the root level in such a case
+            so that the arguments get generated as
+        '--input.path --output.path'.
 
-        - formatter_class : Type[HelpFormatter], optional
+    - formatter_class : Type[HelpFormatter], optional
 
-            The formatter class to use. By default, uses
-            `simple_parsing.SimpleHelpFormatter`, which is a combination of the
-            `argparse.ArgumentDefaultsHelpFormatter`,
-            `argparse.MetavarTypeHelpFormatter` and
-            `argparse.RawDescriptionHelpFormatter` classes.
-        """
+        The formatter class to use. By default, uses
+        `simple_parsing.SimpleHelpFormatter`, which is a combination of the
+        `argparse.ArgumentDefaultsHelpFormatter`,
+        `argparse.MetavarTypeHelpFormatter` and
+        `argparse.RawDescriptionHelpFormatter` classes.
+
+    - add_config_path_arg : bool, optional
+        When set to `True`, adds a `--config_path` argument, of type Path, which is used to parse
+
+    """
+
+    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,
+        nested_mode: NestedMode = NestedMode.DEFAULT,
+        formatter_class: type[HelpFormatter] = SimpleHelpFormatter,
+        add_dest_to_option_strings: bool | None = None,
+        add_config_path_arg: bool | None = None,
+        config_path: Path | str | Sequence[Path | str] | None = None,
+        **kwargs,
+    ):
         kwargs["formatter_class"] = formatter_class
         # Pass parents=[] since we override this mechanism below.
         # NOTE: We end up with the same parents.
@@ -101,11 +112,15 @@ class ArgumentParser(argparse.ArgumentParser):
         self.conflict_resolution = conflict_resolution
         # constructor arguments for the dataclass instances.
         # (a Dict[dest, [attribute, value]])
+        # TODO: Stop using a defaultdict for the very important `self.constructor_arguments`!
         self.constructor_arguments: dict[str, dict] = defaultdict(dict)
 
         self._conflict_resolver = ConflictResolver(self.conflict_resolution)
         self._wrappers: list[DataclassWrapper] = []
 
+        if add_dest_to_option_strings:
+            argument_generation_mode = ArgumentGenerationMode.BOTH
+
         self._preprocessing_done: bool = False
         self.add_option_string_dash_variants = add_option_string_dash_variants
         self.argument_generation_mode = argument_generation_mode
@@ -137,6 +152,12 @@ class ArgumentParser(argparse.ArgumentParser):
             for add_arguments_call in parent._add_argument_replay:
                 add_arguments_call(self)
 
+        self.config_path = Path(config_path) if isinstance(config_path, str) else config_path
+        if add_config_path_arg is None:
+            # By default, add a config path argument if a config path was passed.
+            add_config_path_arg = bool(config_path)
+        self.add_config_path_arg = add_config_path_arg
+
     def add_argument(
         self,
         *name_or_flags: str,
@@ -159,8 +180,9 @@ class ArgumentParser(argparse.ArgumentParser):
         self,
         dataclass: type[Dataclass],
         dest: str,
+        *,
         prefix: str = "",
-        default: Dataclass = None,
+        default: Dataclass | None = None,
         dataclass_wrapper_class: type[DataclassWrapper] = DataclassWrapper,
     ):
         pass
@@ -170,6 +192,7 @@ class ArgumentParser(argparse.ArgumentParser):
         self,
         dataclass: type,
         dest: str,
+        *,
         prefix: str = "",
         dataclass_wrapper_class: type[DataclassWrapper] = DataclassWrapper,
     ):
@@ -179,8 +202,9 @@ class ArgumentParser(argparse.ArgumentParser):
         self,
         dataclass: type[Dataclass] | Dataclass,
         dest: str,
+        *,
         prefix: str = "",
-        default: Dataclass | dict = None,
+        default: Dataclass = None,
         dataclass_wrapper_class: type[DataclassWrapper] = DataclassWrapper,
     ):
         """Adds command-line arguments for the fields of `dataclass`.
@@ -216,15 +240,7 @@ class ArgumentParser(argparse.ArgumentParser):
         )
         for wrapper in self._wrappers:
             if wrapper.dest == dest:
-                if (
-                    wrapper.dataclass
-                    == dataclass
-                    # and wrapper.prefix == prefix
-                    # and wrapper.default == default
-                    # and type(wrapper) is dataclass_wrapper_class
-                ):
-                    # pass  # allow overwriting stuff?
-                    # return
+                if wrapper.dataclass == dataclass:
                     raise argparse.ArgumentError(
                         argument=None,
                         message=f"Destination attribute {dest} is already used for "
@@ -236,12 +252,26 @@ class ArgumentParser(argparse.ArgumentParser):
             dataclass = type(dataclass)
 
         new_wrapper = dataclass_wrapper_class(dataclass, dest, prefix=prefix, default=default)
+
+        if new_wrapper.dest in self._defaults:
+            new_wrapper.default = self._defaults[new_wrapper.dest]
+        if self.nested_mode == NestedMode.WITHOUT_ROOT and all(
+            field.name in self._defaults for field in new_wrapper.fields
+        ):
+            # If we did .set_defaults before we knew what dataclass we're using, then we try to
+            # still make use of those defaults:
+            new_wrapper.default = {
+                k: v
+                for k, v in self._defaults.items()
+                if k in [f.name for f in dataclasses.fields(new_wrapper.dataclass)]
+            }
+
         self._wrappers.append(new_wrapper)
 
     def parse_known_args(
         self,
-        args: Sequence[str] = None,
-        namespace: Namespace = None,
+        args: Sequence[str] | None = None,
+        namespace: Namespace | None = None,
         attempt_to_reorder: bool = False,
     ):
         # NOTE: since the usual ArgumentParser.parse_args() calls
@@ -257,6 +287,39 @@ class ArgumentParser(argparse.ArgumentParser):
         # default Namespace built from parser defaults
         if namespace is None:
             namespace = Namespace()
+        if self.config_path:
+            if isinstance(self.config_path, Path):
+                config_paths = [self.config_path]
+            else:
+                config_paths = self.config_path
+            for config_file in config_paths:
+                self.set_defaults(config_file)
+
+        if self.add_config_path_arg:
+            temp_parser = ArgumentParser(add_config_path_arg=False, add_help=False)
+            temp_parser.add_argument(
+                "--config_path",
+                type=Path,
+                nargs="*",
+                default=self.config_path,
+                help="Path to a config file containing default values to use.",
+            )
+            args_for_config_path, args = temp_parser.parse_known_args(args)
+            config_path = args_for_config_path.config_path
+
+            if config_path is not None:
+                config_paths = config_path if isinstance(config_path, list) else [config_path]
+                for config_file in config_paths:
+                    self.set_defaults(config_file)
+
+            # Adding it here just so it shows up in the help message. The default will be set in
+            # the help string.
+            self.add_argument(
+                "--config_path",
+                type=Path,
+                default=config_path,
+                help="Path to a config file containing default values to use.",
+            )
 
         self._preprocessing()
 
@@ -392,6 +455,61 @@ class ArgumentParser(argparse.ArgumentParser):
             return parser.print_help(file)
         return super().print_help(file)
 
+    def set_defaults(self, config_path: str | Path | None = None, **kwargs: Any) -> None:
+        """Set the default argument values, either from a config file, or from the given kwargs."""
+        if config_path:
+            defaults = read_file(config_path)
+            if self.nested_mode == NestedMode.WITHOUT_ROOT and len(self._wrappers) == 1:
+                # The file should have the same format as the command-line args, e.g. contain the
+                # fields of the 'root' dataclass directly (e.g. "foo: 123"), rather a dict with
+                # "config: foo: 123" where foo is a field of the root dataclass at dest 'config'.
+                # Therefore, we add the prefix back here.
+                defaults = {self._wrappers[0].dest: defaults}
+                # We also assume that the kwargs are passed as foo=123
+                kwargs = {self._wrappers[0].dest: kwargs}
+            # Also include the values from **kwargs.
+            kwargs = dict_union(defaults, kwargs)
+
+        # The kwargs that are set in the dataclasses, rather than on the namespace.
+        kwarg_defaults_set_in_dataclasses = {}
+        for wrapper in self._wrappers:
+            if wrapper.dest in kwargs:
+                default_for_dataclass = kwargs[wrapper.dest]
+
+                if isinstance(default_for_dataclass, (str, Path)):
+                    default_for_dataclass = read_file(path=default_for_dataclass)
+                elif not isinstance(default_for_dataclass, dict) and not dataclasses.is_dataclass(
+                    default_for_dataclass
+                ):
+                    raise ValueError(
+                        f"Got a default for field {wrapper.dest} that isn't a dataclass, dict or "
+                        f"path: {default_for_dataclass}"
+                    )
+
+                # Set the .default attribute on the DataclassWrapper (which also updates the
+                # defaults of the fields and any nested dataclass fields).
+                wrapper.default = default_for_dataclass
+
+                # It's impossible for multiple wrappers in kwargs to have the same destination.
+                assert wrapper.dest not in kwarg_defaults_set_in_dataclasses
+                value_for_constructor_arguments = (
+                    default_for_dataclass
+                    if isinstance(default_for_dataclass, dict)
+                    else dataclasses.asdict(default_for_dataclass)
+                )
+                kwarg_defaults_set_in_dataclasses[wrapper.dest] = value_for_constructor_arguments
+                # Remove this from the **kwargs, so they don't get set on the namespace.
+                kwargs.pop(wrapper.dest)
+        # TODO: Stop using a defaultdict for the very important `self.constructor_arguments`!
+        self.constructor_arguments = dict_union(
+            self.constructor_arguments,
+            kwarg_defaults_set_in_dataclasses,
+            dict_factory=lambda: defaultdict(dict),
+        )
+        # For the rest of the values, use the default argparse behaviour (modifying the
+        # self._defaults dictionary).
+        super().set_defaults(**kwargs)
+
     def equivalent_argparse_code(self) -> str:
         """Returns the argparse code equivalent to that of `simple_parsing`.
 
@@ -472,8 +590,6 @@ class ArgumentParser(argparse.ArgumentParser):
 
         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.
@@ -600,15 +716,23 @@ class ArgumentParser(argparse.ArgumentParser):
                     )
 
                     setattr(parsed_args, destination, instance)
+                # There is a collision: namespace already has an entry at this destination.
                 elif not self.subgroups:
                     existing = getattr(parsed_args, destination)
-                    raise RuntimeError(
-                        f"Namespace should not already have a '{destination}' "
-                        f"attribute!\n"
-                        f"The value would be overwritten:\n"
-                        f"- existing value: {existing}\n"
-                        f"- new value:      {instance}"
-                    )
+                    if wrapper.dest in self._defaults:
+                        logger.debug(
+                            f"Overwriting defaults in the namespace at destination '{destination}' "
+                            f"on the Namespace ({existing}) to a value of {instance}"
+                        )
+                        setattr(parsed_args, destination, instance)
+                    else:
+                        raise RuntimeError(
+                            f"Namespace should not already have a '{destination}' "
+                            f"attribute!\n"
+                            f"The value would be overwritten:\n"
+                            f"- existing value: {existing}\n"
+                            f"- new value:      {instance}"
+                        )
                 elif not any(wrapper.dest in subgroup_dest for subgroup_dest in self.subgroups):
                     # the current dataclass wrapper doesn't have anything to do with the subgroups.
                     existing = getattr(parsed_args, destination)
@@ -620,14 +744,6 @@ class ArgumentParser(argparse.ArgumentParser):
                             f"Ignoring new parsed value of {instance} for {destination}. \n"
                             f"Keeping the value that is already in the args: {existing}"
                         )
-                        # raise RuntimeError(
-                        #     "Why is there already an attribute, if this doesn't have anything to do with subgroups?\n"
-                        #     f"Namespace should not already have a '{destination}' "
-                        #     f"attribute!\n"
-                        #     f"The value would be overwritten:\n"
-                        #     f"- existing value: {existing}\n"
-                        #     f"- new value:      {instance}"
-                        # )
                 else:
                     # The dataclass wrapper isn't directly for the subgroup: It's for a parent of
                     # the subgroup choice.
@@ -759,3 +875,30 @@ class ArgumentParser(argparse.ArgumentParser):
             logger.debug(f"deleted values: {deleted_values}")
             logger.debug(f"leftover args: {leftover_args}")
         return leftover_args
+
+
+T = TypeVar("T")
+
+
+def parse(
+    config_class: type[Dataclass],
+    config_path: Path | str | None = None,
+    args: str | Sequence[str] | None = None,
+) -> Dataclass:
+    """Parse the given dataclass from the command-line.
+
+    If `config_path` is passed, loads the values from that file and uses them as defaults.
+    """
+
+    if isinstance(args, str):
+        args = shlex.split(args)
+    parser = ArgumentParser(
+        nested_mode=NestedMode.WITHOUT_ROOT,
+        add_help=True,
+        add_config_path_arg=True,
+        config_path=config_path,
+    )
+    parser.add_arguments(config_class, dest="config")
+    parsed_args = parser.parse_args(args)
+    config: Dataclass = parsed_args.config
+    return config
diff --git a/simple_parsing/utils.py b/simple_parsing/utils.py
index 507ab8e..40b32d3 100644
--- a/simple_parsing/utils.py
+++ b/simple_parsing/utils.py
@@ -1,4 +1,6 @@
 """Utility functions used in various parts of the simple_parsing package."""
+from __future__ import annotations
+
 import argparse
 import builtins
 import dataclasses
@@ -13,7 +15,7 @@ import typing
 from collections import OrderedDict
 from collections import abc as c_abc
 from collections import defaultdict
-from dataclasses import _MISSING_TYPE, Field
+from dataclasses import _MISSING_TYPE, MISSING, Field
 from enum import Enum
 from logging import getLogger
 from typing import (
@@ -25,12 +27,12 @@ from typing import (
     List,
     Mapping,
     MutableMapping,
-    Optional,
     Set,
     Tuple,
     Type,
     TypeVar,
     Union,
+    overload,
 )
 
 from typing_extensions import Literal, get_args
@@ -85,6 +87,9 @@ DataclassType = Type[Dataclass]
 SimpleValueType = Union[bool, int, float, str]
 SimpleIterable = Union[List[SimpleValueType], Dict[Any, SimpleValueType], Set[SimpleValueType]]
 
+PossiblyNestedDict = Dict[K, Union[V, "PossiblyNestedDict[K, V]"]]
+PossiblyNestedMapping = Mapping[K, Union[V, "PossiblyNestedMapping[K, V]"]]
+
 
 def is_subparser_field(field: Field) -> bool:
     if is_union(field.type) and not is_choice(field):
@@ -107,11 +112,11 @@ def camel_case(name):
     return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
 
 
-TRUE_STRINGS: List[str] = ["yes", "true", "t", "y", "1"]
-FALSE_STRINGS: List[str] = ["no", "false", "f", "n", "0"]
+TRUE_STRINGS: list[str] = ["yes", "true", "t", "y", "1"]
+FALSE_STRINGS: list[str] = ["no", "false", "f", "n", "0"]
 
 
-def str2bool(raw_value: Union[str, bool]) -> bool:
+def str2bool(raw_value: str | bool) -> bool:
     """
     Taken from https://stackoverflow.com/questions/15008758/parsing-boolean-values-with-argparse
     """
@@ -128,7 +133,7 @@ def str2bool(raw_value: Union[str, bool]) -> bool:
         )
 
 
-def get_item_type(container_type: Type[Container[T]]) -> T:
+def get_item_type(container_type: type[Container[T]]) -> T:
     """Returns the `type` of the items in the provided container `type`.
 
     When no type annotation is found, or no item type is found, returns
@@ -192,8 +197,8 @@ def get_item_type(container_type: Type[Container[T]]) -> T:
 
 
 def get_argparse_type_for_container(
-    container_type: Type[Container[T]],
-) -> Union[Type[T], Callable[[str], T]]:
+    container_type: type[Container[T]],
+) -> type[T] | Callable[[str], T]:
     """Gets the argparse 'type' option to be used for a given container type.
     When an annotation is present, the 'type' option of argparse is set to that type.
     if not, then the default value of 'str' is returned.
@@ -223,7 +228,7 @@ def get_argparse_type_for_container(
     return T
 
 
-def _mro(t: Type) -> List[Type]:
+def _mro(t: type) -> list[type]:
     # TODO: This is mostly used in 'is_tuple' and such, and should be replaced with
     # either the built-in 'get_origin' from typing, or from typing-inspect.
     if t is None:
@@ -258,7 +263,7 @@ def is_literal(t: type) -> bool:
     return get_origin(t) is Literal
 
 
-def is_list(t: Type) -> bool:
+def is_list(t: type) -> bool:
     """returns True when `t` is a List type.
 
     Args:
@@ -289,7 +294,7 @@ def is_list(t: Type) -> bool:
     return list in _mro(t)
 
 
-def is_tuple(t: Type) -> bool:
+def is_tuple(t: type) -> bool:
     """returns True when `t` is a tuple type.
 
     Args:
@@ -320,7 +325,7 @@ def is_tuple(t: Type) -> bool:
     return tuple in _mro(t)
 
 
-def is_dict(t: Type) -> bool:
+def is_dict(t: type) -> bool:
     """returns True when `t` is a dict type or annotation.
 
     Args:
@@ -357,7 +362,7 @@ def is_dict(t: Type) -> bool:
     return dict in mro or Mapping in mro or c_abc.Mapping in mro
 
 
-def is_set(t: Type) -> bool:
+def is_set(t: type) -> bool:
     """returns True when `t` is a set type or annotation.
 
     Args:
@@ -390,7 +395,7 @@ def is_set(t: Type) -> bool:
     return set in _mro(t)
 
 
-def is_dataclass_type(t: Type) -> bool:
+def is_dataclass_type(t: type) -> bool:
     """Returns whether t is a dataclass type or a TypeVar of a dataclass type.
 
     Args:
@@ -402,21 +407,21 @@ def is_dataclass_type(t: Type) -> bool:
     return dataclasses.is_dataclass(t) or (is_typevar(t) and dataclasses.is_dataclass(get_bound(t)))
 
 
-def is_enum(t: Type) -> bool:
+def is_enum(t: type) -> bool:
     if inspect.isclass(t):
         return issubclass(t, enum.Enum)
     return Enum in _mro(t)
 
 
-def is_bool(t: Type) -> bool:
+def is_bool(t: type) -> bool:
     return bool in _mro(t)
 
 
-def is_tuple_or_list(t: Type) -> bool:
+def is_tuple_or_list(t: type) -> bool:
     return is_list(t) or is_tuple(t)
 
 
-def is_union(t: Type) -> bool:
+def is_union(t: type) -> bool:
     """Returns whether or not the given Type annotation is a variant (or subclass) of typing.Union
 
     Args:
@@ -438,7 +443,7 @@ def is_union(t: Type) -> bool:
     return getattr(t, "__origin__", "") == Union
 
 
-def is_homogeneous_tuple_type(t: Type[Tuple]) -> bool:
+def is_homogeneous_tuple_type(t: type[tuple]) -> bool:
     """Returns whether the given Tuple type is homogeneous: if all items types are the
     same.
 
@@ -482,7 +487,7 @@ def is_choice(field: Field) -> bool:
     return bool(field.metadata.get("custom_args", {}).get("choices", {}))
 
 
-def is_optional(t: Type) -> bool:
+def is_optional(t: type) -> bool:
     """Returns True if the given Type is a variant of the Optional type.
 
     Parameters
@@ -511,11 +516,11 @@ def is_optional(t: Type) -> bool:
     return is_union(t) and type(None) in get_type_arguments(t)
 
 
-def is_tuple_or_list_of_dataclasses(t: Type) -> bool:
+def is_tuple_or_list_of_dataclasses(t: type) -> bool:
     return is_tuple_or_list(t) and is_dataclass_type(get_item_type(t))
 
 
-def contains_dataclass_type_arg(t: Type) -> bool:
+def contains_dataclass_type_arg(t: type) -> bool:
     if is_dataclass_type(t):
         return True
     elif is_tuple_or_list_of_dataclasses(t):
@@ -525,7 +530,7 @@ def contains_dataclass_type_arg(t: Type) -> bool:
     return False
 
 
-def get_dataclass_type_arg(t: Type) -> Optional[Type]:
+def get_dataclass_type_arg(t: type) -> type | None:
     if not contains_dataclass_type_arg(t):
         return None
     if is_dataclass_type(t):
@@ -538,12 +543,12 @@ def get_dataclass_type_arg(t: Type) -> Optional[Type]:
     return None
 
 
-def get_type_arguments(container_type: Type) -> Tuple[Type, ...]:
+def get_type_arguments(container_type: type) -> tuple[type, ...]:
     # return getattr(container_type, "__args__", ())
     return get_args(container_type)
 
 
-def get_type_name(some_type: Type):
+def get_type_name(some_type: type):
     result = getattr(some_type, "__name__", str(some_type))
     type_arguments = get_type_arguments(some_type)
     if type_arguments:
@@ -551,7 +556,7 @@ def get_type_name(some_type: Type):
     return result
 
 
-def get_container_nargs(container_type: Type) -> Union[int, str]:
+def get_container_nargs(container_type: type) -> int | str:
     """Gets the value of 'nargs' appropriate for the given container type.
 
     Parameters
@@ -567,7 +572,7 @@ def get_container_nargs(container_type: Type) -> Union[int, str]:
     if is_tuple(container_type):
         # TODO: Should a `Tuple[int]` annotation be interpreted as "a tuple of an
         # unknown number of ints"?.
-        type_arguments: Tuple[Type, ...] = get_type_arguments(container_type)
+        type_arguments: tuple[type, ...] = get_type_arguments(container_type)
         if not type_arguments:
             return "*"
         if len(type_arguments) == 2 and type_arguments[1] is Ellipsis:
@@ -602,7 +607,7 @@ def get_container_nargs(container_type: Type) -> Union[int, str]:
 
 def _parse_multiple_containers(
     container_type: type, append_action: bool = False
-) -> Callable[[str], List[Any]]:
+) -> Callable[[str], list[Any]]:
     T = get_argparse_type_for_container(container_type)
     factory = tuple if is_tuple(container_type) else list
 
@@ -623,12 +628,12 @@ def _parse_multiple_containers(
     return parse_fn
 
 
-def _parse_container(container_type: Type[Container]) -> Callable[[str], List[Any]]:
+def _parse_container(container_type: type[Container]) -> Callable[[str], list[Any]]:
     T = get_argparse_type_for_container(container_type)
     factory = tuple if is_tuple(container_type) else list
     import ast
 
-    def _parse(value: str) -> List[Any]:
+    def _parse(value: str) -> list[Any]:
         logger.debug(f"Parsing a {container_type} of {T}s, value is: '{value}'")
         try:
             values = _parse_literal(value)
@@ -642,7 +647,7 @@ def _parse_container(container_type: Type[Container]) -> Callable[[str], List[An
         logger.debug(f"returning values: {values}")
         return values
 
-    def _parse_literal(value: str) -> Union[List[Any], Any]:
+    def _parse_literal(value: str) -> list[Any] | Any:
         """try to parse the string to a python expression directly.
         (useful for nested lists or tuples.)
         """
@@ -657,7 +662,7 @@ def _parse_container(container_type: Type[Container]) -> Callable[[str], List[An
             values = factory(T(v) for v in container)
             return values
 
-    def _fallback_parse(v: str) -> List[Any]:
+    def _fallback_parse(v: str) -> list[Any]:
         v = " ".join(v.split())
         if v.startswith("[") and v.endswith("]"):
             v = v[1:-1]
@@ -694,7 +699,7 @@ def getattr_recursive(obj: object, attribute_name: str):
         return getattr_recursive(child_object, rest_of_attribute_name)
 
 
-def split_dest(destination: str) -> Tuple[str, str]:
+def split_dest(destination: str) -> tuple[str, str]:
     parent, _, attribute_in_parent = destination.rpartition(".")
     return parent, attribute_in_parent
 
@@ -708,7 +713,7 @@ def get_nesting_level(possibly_nested_list):
         return 1 + max(get_nesting_level(item) for item in possibly_nested_list)
 
 
-def default_value(field: dataclasses.Field) -> Union[T, _MISSING_TYPE]:
+def default_value(field: dataclasses.Field) -> T | _MISSING_TYPE:
     """Returns the default value of a field in a dataclass, if available.
     When not available, returns `dataclasses.MISSING`.
 
@@ -727,7 +732,7 @@ def default_value(field: dataclasses.Field) -> Union[T, _MISSING_TYPE]:
         return dataclasses.MISSING
 
 
-def trie(sentences: List[List[str]]) -> Dict[str, Union[str, Dict]]:
+def trie(sentences: list[list[str]]) -> dict[str, str | dict]:
     """Given a list of sentences, creates a trie as a nested dicts of word strings.
 
     Args:
@@ -737,12 +742,12 @@ def trie(sentences: List[List[str]]) -> Dict[str, Union[str, Dict]]:
         Dict[str, Union[str, Dict[str, ...]]]: A tree where each node is a word in a sentence.
         Sentences which begin with the same words share the first nodes, etc.
     """
-    first_word_to_sentences: Dict[str, List[List[str]]] = defaultdict(list)
+    first_word_to_sentences: dict[str, list[list[str]]] = defaultdict(list)
     for sentence in sentences:
         first_word = sentence[0]
         first_word_to_sentences[first_word].append(sentence)
 
-    return_dict: Dict[str, Union[str, Dict]] = {}
+    return_dict: dict[str, str | dict] = {}
     for first_word, sentences in first_word_to_sentences.items():
         if len(sentences) == 1:
             return_dict[first_word] = ".".join(sentences[0])
@@ -752,7 +757,7 @@ def trie(sentences: List[List[str]]) -> Dict[str, Union[str, Dict]]:
     return return_dict
 
 
-def keep_keys(d: Dict, keys_to_keep: Iterable[str]) -> Tuple[Dict, Dict]:
+def keep_keys(d: dict, keys_to_keep: Iterable[str]) -> tuple[dict, dict]:
     """Removes all the keys in `d` that aren't in `keys`.
 
     Parameters
@@ -802,7 +807,7 @@ def compute_identity(size: int = 16, **sample) -> str:
     return sample_hash.hexdigest()[:size]
 
 
-def dict_intersection(*dicts: Dict[K, V]) -> Iterable[Tuple[K, Tuple[V, ...]]]:
+def dict_intersection(*dicts: dict[K, V]) -> Iterable[tuple[K, tuple[V, ...]]]:
     common_keys = set(dicts[0])
     for d in dicts:
         common_keys.intersection_update(d)
@@ -810,14 +815,14 @@ def dict_intersection(*dicts: Dict[K, V]) -> Iterable[Tuple[K, Tuple[V, ...]]]:
         yield (key, tuple(d[key] for d in dicts))
 
 
-def field_dict(dataclass: Dataclass) -> Dict[str, Field]:
-    result: Dict[str, Field] = OrderedDict()
+def field_dict(dataclass: Dataclass) -> dict[str, Field]:
+    result: dict[str, Field] = OrderedDict()
     for field in dataclasses.fields(dataclass):
         result[field.name] = field
     return result
 
 
-def zip_dicts(*dicts: Dict[K, V]) -> Iterable[Tuple[K, Tuple[Optional[V], ...]]]:
+def zip_dicts(*dicts: dict[K, V]) -> Iterable[tuple[K, tuple[V | None, ...]]]:
     # If any attributes are common to both the Experiment and the State,
     # copy them over to the Experiment.
     keys = set(itertools.chain(*dicts))
@@ -825,7 +830,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.
@@ -840,20 +845,20 @@ def dict_union(*dicts: Dict[K, V], recurse: bool = True, dict_factory=dict) -> D
     >>> dict_union(a, b, dict_factory=OrderedDict)
     OrderedDict([('a', 2), ('b', OrderedDict([('c', 3), ('d', 3), ('e', 6)]))])
     """
-    result: Dict = dict_factory()
+    result: dict = dict_factory()
     if not dicts:
         return result
     assert len(dicts) >= 1
-    all_keys: Set[str] = set()
+    all_keys: set[str] = set()
     all_keys.update(*dicts)
     all_keys = sorted(all_keys)
 
     # Create a neat generator of generators, to save some memory.
-    all_values: Iterable[Tuple[V, Iterable[K]]] = (
+    all_values: Iterable[tuple[V, Iterable[K]]] = (
         (k, (d[k] for d in dicts if k in d)) for k in all_keys
     )
     for k, values in all_values:
-        sub_dicts: List[Dict] = []
+        sub_dicts: list[dict] = []
         new_value: V = None
         n_values = 0
         for v in values:
@@ -873,6 +878,70 @@ def dict_union(*dicts: Dict[K, V], recurse: bool = True, dict_factory=dict) -> D
     return result
 
 
+def flatten(nested: PossiblyNestedMapping[K, V]) -> dict[tuple[K, ...], V]:
+    """Flatten a dictionary of dictionaries. The returned dictionary's keys are tuples, one entry
+    per layer.
+
+    >>> flatten({"a": {"b": 2, "c": 3}, "c": {"d": 3, "e": 4}})
+    {('a', 'b'): 2, ('a', 'c'): 3, ('c', 'd'): 3, ('c', 'e'): 4}
+    """
+    flattened: dict[tuple[K, ...], V] = {}
+    for k, v in nested.items():
+        if isinstance(v, c_abc.Mapping):
+            for subkeys, subv in flatten(v).items():
+                collision_key = (k, *subkeys)
+                assert collision_key not in flattened
+                flattened[collision_key] = subv
+        else:
+            flattened[(k,)] = v
+    return flattened
+
+
+def unflatten(flattened: Mapping[tuple[K, ...], V]) -> PossiblyNestedDict[K, V]:
+    """Unflatten a dictionary back into a possibly nested dictionary.
+
+    >>> unflatten({('a', 'b'): 2, ('a', 'c'): 3, ('c', 'd'): 3, ('c', 'e'): 4})
+    {'a': {'b': 2, 'c': 3}, 'c': {'d': 3, 'e': 4}}
+    """
+    nested: PossiblyNestedDict[K, V] = {}
+    for keys, value in flattened.items():
+        sub_dictionary = nested
+        for part in keys[:-1]:
+            assert isinstance(sub_dictionary, dict)
+            sub_dictionary = sub_dictionary.setdefault(part, {})
+        assert isinstance(sub_dictionary, dict)
+        sub_dictionary[keys[-1]] = value
+    return nested
+
+
+def flatten_join(nested: PossiblyNestedMapping[str, V], sep: str = ".") -> dict[str, V]:
+    """Flatten a dictionary of dictionaries. When collisions occur, joins the keys with `sep`."""
+    return {sep.join(keys): value for keys, value in flatten(nested).items()}
+
+
+def unflatten_split(flattened: Mapping[str, V], sep: str = ".") -> PossiblyNestedDict[str, V]:
+    """Unflatten a dict into a possibly nested dict. Keys are split using `sep`.,"""
+    return unflatten({tuple(key.split(sep)): value for key, value in flattened.items()})
+
+
+@overload
+def getitem_recursive(d: PossiblyNestedDict[K, V], keys: Iterable[K]) -> V:
+    ...
+
+
+@overload
+def getitem_recursive(d: PossiblyNestedDict[K, V], keys: Iterable[K], default: T) -> V | T:
+    ...
+
+
+def getitem_recursive(
+    d: PossiblyNestedDict[K, V], keys: Iterable[K], default: T | _MISSING_TYPE = MISSING
+) -> V | T:
+    if default is not MISSING:
+        return flatten(d).get(tuple(keys), default)
+    return flatten(d)[tuple(keys)]
+
+
 if __name__ == "__main__":
     import doctest
 
diff --git a/simple_parsing/wrappers/dataclass_wrapper.py b/simple_parsing/wrappers/dataclass_wrapper.py
index bcb4589..0662dee 100644
--- a/simple_parsing/wrappers/dataclass_wrapper.py
+++ b/simple_parsing/wrappers/dataclass_wrapper.py
@@ -1,8 +1,10 @@
+from __future__ import annotations
+
 import argparse
 import dataclasses
 from dataclasses import MISSING
 from logging import getLogger
-from typing import Dict, List, Optional, Type, Union, cast
+from typing import cast
 
 from .. import docstring, utils
 from ..utils import Dataclass
@@ -15,32 +17,32 @@ logger = getLogger(__name__)
 class DataclassWrapper(Wrapper[Dataclass]):
     def __init__(
         self,
-        dataclass: Type[Dataclass],
+        dataclass: type[Dataclass],
         name: str,
-        default: Union[Dataclass, Dict] = None,
+        default: Dataclass | dict = None,
         prefix: str = "",
-        parent: Optional["DataclassWrapper"] = None,
-        _field: Optional[dataclasses.Field] = None,
-        field_wrapper_class: Type[FieldWrapper] = FieldWrapper,
+        parent: DataclassWrapper | None = None,
+        _field: dataclasses.Field | None = None,
+        field_wrapper_class: type[FieldWrapper] = FieldWrapper,
     ):
         # super().__init__(dataclass, name)
         self.dataclass = dataclass
         self._name = name
-        self.default = default
+        self._default = default
         self.prefix = prefix
 
-        self.fields: List[FieldWrapper] = []
-        self._destinations: List[str] = []
+        self.fields: list[FieldWrapper] = []
+        self._destinations: list[str] = []
         self._required: bool = False
         self._explicit: bool = False
         self._dest: str = ""
-        self._children: List[DataclassWrapper] = []
+        self._children: list[DataclassWrapper] = []
         self._parent = parent
         # the field of the parent, which contains this child dataclass.
         self._field = _field
 
         # the default values
-        self._defaults: List[Dataclass] = []
+        self._defaults: list[Dataclass] = []
 
         if default:
             self.defaults = [default]
@@ -172,11 +174,11 @@ class DataclassWrapper(Wrapper[Dataclass]):
         return self._name
 
     @property
-    def parent(self) -> Optional["DataclassWrapper"]:
+    def parent(self) -> DataclassWrapper | None:
         return self._parent
 
     @property
-    def defaults(self) -> List[Dataclass]:
+    def defaults(self) -> list[Dataclass]:
         if self._defaults:
             return self._defaults
         if self._field is None:
@@ -197,9 +199,28 @@ class DataclassWrapper(Wrapper[Dataclass]):
         return self._defaults
 
     @defaults.setter
-    def defaults(self, value: List[Dataclass]):
+    def defaults(self, value: list[Dataclass]):
         self._defaults = value
 
+    @property
+    def default(self) -> Dataclass | None:
+        return self._default
+
+    @default.setter
+    def default(self, value: Dataclass | dict):
+        if value is None:
+            self._default = value
+            return
+        if not isinstance(value, dict):
+            self._default = value
+            value = dataclasses.asdict(value)
+        for field_wrapper in self.fields:
+            if field_wrapper.name in value:
+                field_wrapper.default = value[field_wrapper.name]
+        for nested_dataclass_wrapper in self._children:
+            if nested_dataclass_wrapper.name in value:
+                nested_dataclass_wrapper.default = value[nested_dataclass_wrapper.name]
+
     @property
     def title(self) -> str:
         names_string = f""" [{', '.join(f"'{dest}'" for dest in self.destinations)}]"""
@@ -265,7 +286,7 @@ class DataclassWrapper(Wrapper[Dataclass]):
         return _dest
 
     @property
-    def destinations(self) -> List[str]:
+    def destinations(self) -> list[str]:
         if not self._destinations:
             if self.parent:
                 self._destinations = [f"{d}.{self.name}" for d in self.parent.destinations]
@@ -274,10 +295,10 @@ class DataclassWrapper(Wrapper[Dataclass]):
         return self._destinations
 
     @destinations.setter
-    def destinations(self, value: List[str]):
+    def destinations(self, value: list[str]):
         self._destinations = value
 
-    def merge(self, other: "DataclassWrapper"):
+    def merge(self, other: DataclassWrapper):
         """Absorb all the relevant attributes from another wrapper.
         Args:
             other (DataclassWrapper): Another instance to absorb into this one.
diff --git a/simple_parsing/wrappers/field_wrapper.py b/simple_parsing/wrappers/field_wrapper.py
index b625d62..65c83a3 100644
--- a/simple_parsing/wrappers/field_wrapper.py
+++ b/simple_parsing/wrappers/field_wrapper.py
@@ -191,6 +191,7 @@ class FieldWrapper(Wrapper[dataclasses.Field]):
             parent_dest, attribute = utils.split_dest(destination)
             value = self.postprocess(value)
             self._results[destination] = value
+
             parser.constructor_arguments[parent_dest][attribute] = value
             logger.debug(
                 f"setting value of {value} in constructor arguments "
@@ -302,7 +303,6 @@ class FieldWrapper(Wrapper[dataclasses.Field]):
                 _arg_options["type"] = get_parsing_fn(wrapped_type)
                 # TODO: Should the 'nargs' really be '?' here?
                 _arg_options["nargs"] = "?"
-                # assert False, (wrapped_type, utils.is_tuple(wrapped_type))
 
         elif self.is_union:
             logger.debug("Parsing a Union type!")


Test patch:
diff --git a/test/test_examples.py b/test/test_examples.py
index 3c90f33..d20967c 100644
--- a/test/test_examples.py
+++ b/test/test_examples.py
@@ -2,12 +2,17 @@
 (Could be seen as a kind of integration test.)
 
 """
+from __future__ import annotations
+
 import glob
+import os
+import runpy
 import shlex
 import sys
 from collections import Counter
+from contextlib import contextmanager
 from pathlib import Path
-from typing import Callable, Optional
+from typing import Callable, Sequence
 
 import pytest
 
@@ -71,31 +76,29 @@ def assert_equals_stdout(capsys):
 def test_running_example_outputs_expected(
     file_path: str,
     args: str,
-    set_prog_name: Callable[[str, Optional[str]], None],
+    set_prog_name: Callable[[str, str | None], None],
     assert_equals_stdout: Callable[[str, str], None],
 ):
     # set_prog_name(script, args)
-    file_path = Path(file_path).as_posix()
-    module_name = file_path.replace("/", ".").replace(".py", "")
-    try:
-        # programmatically import the example script, which also runs it.
-        # (Equivalent to executing "from <module_name> import expected")
-        import runpy
+    example_dir = Path(file_path).parent
 
+    file_path = Path(file_path).as_posix()
+    module_name = file_path.replace("/", ".")
+    if module_name.endswith(".py"):
+        module_name = module_name[:-3]
+    # programmatically import the example script, which also runs it.
+    # (Equivalent to executing "from <module_name> import expected")
+
+    # move to the example directory.
+    with temporarily_chdir(example_dir), temporarily_add_args(args):
         resulting_globals = runpy.run_module(
             module_name, init_globals=None, run_name="__main__", alter_sys=True
         )
-        # module = __import__(module_name, globals(), locals(), ["expected"], 0)
-        # resulting_globals = vars(module)
-        # get the 'expected'
-        # assert "expected" in resulting_globals
-        if "expected" not in resulting_globals:
-            pytest.xfail(reason="Example doesn't have an 'expected' global variable.")
-        expected = resulting_globals["expected"]
-        assert_equals_stdout(expected, file_path)
 
-    except SystemExit:
-        pytest.xfail(f"SystemExit in example {file_path}.")
+    if "expected" not in resulting_globals:
+        pytest.xfail(reason="Example doesn't have an 'expected' global variable.")
+    expected = resulting_globals["expected"]
+    assert_equals_stdout(expected, file_path)
 
 
 @pytest.mark.parametrize(
@@ -128,7 +131,33 @@ def test_running_example_outputs_expected(
 )
 def test_running_example_outputs_expected_without_arg(
     file_path: str,
-    set_prog_name: Callable[[str, Optional[str]], None],
+    set_prog_name: Callable[[str, str | None], None],
     assert_equals_stdout: Callable[[str, str], None],
 ):
     return test_running_example_outputs_expected(file_path, "", set_prog_name, assert_equals_stdout)
+
+
+@contextmanager
+def temporarily_chdir(new_dir: Path):
+    """Temporarily navigate to the given directory."""
+    start_dir = Path.cwd()
+    try:
+        os.chdir(new_dir)
+        yield
+    except OSError:
+        raise
+    finally:
+        os.chdir(start_dir)
+
+
+@contextmanager
+def temporarily_add_args(args: str | Sequence[str]):
+    """Temporarily adds the given arguments to sys.argv."""
+    if isinstance(args, str):
+        args = shlex.split(args)
+    start_argv = sys.argv.copy()
+    try:
+        sys.argv = start_argv + list(args)
+        yield
+    finally:
+        sys.argv = start_argv
diff --git a/test/test_set_defaults.py b/test/test_set_defaults.py
new file mode 100644
index 0000000..0a20891
--- /dev/null
+++ b/test/test_set_defaults.py
@@ -0,0 +1,110 @@
+""" Tests for the setdefaults method of the parser. """
+from dataclasses import dataclass, field
+from pathlib import Path
+
+import pytest
+import yaml
+
+from simple_parsing.helpers.serialization.serializable import save, to_dict
+from simple_parsing.parsing import ArgumentParser
+from simple_parsing.wrappers.field_wrapper import NestedMode
+
+from .testutils import TestSetup
+
+
+@dataclass
+class Foo(TestSetup):
+    a: int = 123
+    b: str = "hello"
+
+
+def test_set_defaults():
+    parser = ArgumentParser()
+    parser.add_arguments(Foo, dest="foo")
+    parser.set_defaults(foo=Foo(b="HOLA"))
+    args = parser.parse_args("")
+    assert args.foo == Foo(b="HOLA")
+
+
+def test_set_defaults_from_file(tmp_path: Path):
+    parser = ArgumentParser()
+    parser.add_arguments(Foo, dest="foo")
+
+    saved_config = Foo(a=456, b="HOLA")
+    config_path = tmp_path / "foo.yaml"
+    with open(config_path, "w") as f:
+        yaml.dump({"foo": to_dict(saved_config)}, f)
+
+    parser.set_defaults(config_path)
+    args = parser.parse_args("")
+    assert args.foo == saved_config
+
+
+def test_set_defaults_from_file_without_root(tmp_path: Path):
+    """test that set_defaults accepts the fields of the dataclass directly, when the parser has
+    nested_mode=NestedMode.WITHOUT_ROOT.
+    """
+    parser = ArgumentParser(nested_mode=NestedMode.WITHOUT_ROOT)
+    parser.add_arguments(Foo, dest="foo")
+
+    save_path = tmp_path / "temp.json"
+    save(dict(a=456, b="BYE BYE"), path=save_path)
+
+    parser.set_defaults(save_path)
+
+    args = parser.parse_args("")
+    assert args.foo == Foo(a=456, b="BYE BYE")
+
+    args = parser.parse_args("--a 111".split())
+    assert args.foo == Foo(a=111, b="BYE BYE")
+
+
+def test_set_defaults_from_file_before_adding_args(tmp_path: Path):
+    parser = ArgumentParser()
+
+    saved_config = Foo(a=456, b="HOLA")
+    config_path = tmp_path / "foo.yaml"
+    with open(config_path, "w") as f:
+        yaml.dump({"foo": to_dict(saved_config)}, f)
+    parser.set_defaults(config_path)
+
+    parser.add_arguments(Foo, dest="foo")
+    args = parser.parse_args("")
+    assert args.foo == saved_config
+
+
+@dataclass
+class ConfigWithFoo(TestSetup):
+    c: str = "bob"
+    foo: Foo = field(default_factory=Foo)
+
+
+@pytest.mark.parametrize("with_root", [True, False])
+@pytest.mark.parametrize("add_arguments_before", [True, False])
+def test_with_nested_field(tmp_path: Path, add_arguments_before: bool, with_root: bool):
+    """Test that when we use set_defaults with a config that has a nested dataclass field,
+    we can pass a path to a yaml file for one of the field, and it also works.
+    """
+    parser = ArgumentParser(
+        nested_mode=NestedMode.WITHOUT_ROOT if not with_root else NestedMode.DEFAULT
+    )
+    if add_arguments_before:
+        parser.add_arguments(ConfigWithFoo, dest="config")
+
+    save_path = tmp_path / "temp.json"
+    from simple_parsing.helpers.serialization import encode
+
+    if with_root:
+        save(encode({"config": ConfigWithFoo(foo=Foo(a=456, b="BYE BYE"))}), path=save_path)
+    else:
+        save(ConfigWithFoo(foo=Foo(a=456, b="BYE BYE")), path=save_path)
+    parser.set_defaults(save_path)
+
+    if not add_arguments_before:
+        parser.add_arguments(ConfigWithFoo, dest="config")
+
+    args = parser.parse_args("")
+    assert args.config == ConfigWithFoo(foo=Foo(a=456, b="BYE BYE"))
+
+    args = parser.parse_args("--a 111".split())
+    assert args.config == ConfigWithFoo(foo=Foo(a=111, b="BYE BYE"))
diff --git a/test/test_utils.py b/test/test_utils.py
index a927a07..73eadf1 100644
--- a/test/test_utils.py
+++ b/test/test_utils.py
@@ -5,6 +5,7 @@ from typing import Dict, List, Set, Tuple, Type
 import simple_parsing.utils as utils
 from simple_parsing import mutable_field
 from simple_parsing.helpers import dict_field, list_field, set_field
+from simple_parsing.utils import flatten_join, unflatten_split
 
 from .testutils import TestSetup, parametrize
 
@@ -167,3 +168,23 @@ def test_dict_field_without_args():
     a2 = A()
     assert a1.a == a2.a == default
     assert id(a1.a) != id(a2.a)
+
+
+def test_flatten():
+    """Test basic functionality of flatten"""
+    d = {"a": {"b": 2, "c": 3}, "c": {"d": 3, "e": 4}}
+    assert flatten_join(d) == {"a.b": 2, "a.c": 3, "c.d": 3, "c.e": 4}
+
+
+def test_flatten_double_ref():
+    """Test proper handling of double references in dicts"""
+    a = {"b": 2, "c": 3}
+    d = {"a": a, "d": {"e": a}}
+    assert flatten_join(d) == {"a.b": 2, "a.c": 3, "d.e.b": 2, "d.e.c": 3}
+
+
+def test_unflatten():
+    """Test than unflatten(flatten(x)) is idempotent"""
+    a = {"b": 2, "c": 3}
+    d = {"a": a, "d": {"e": a}}
+    assert unflatten_split(flatten_join(d)) == d