Repository: pylhc/generic_parser

Issue:
Optional parameters without default should always accept None 
.. as this is what is written out to config if they are not set.
Crash on non-string invalid choice of parameter
If your your arguments are not of type string and you hand in a value not in the choices, then the error message that should be written crashes, because it is trying to make a string.

in dict_parser.py line 133

![image](https://user-images.githubusercontent.com/26184899/71262220-8042e180-233f-11ea-9ce6-86923c9072ef.png)


should be enough to remove the 's'

Discussion:
mihofer: Fixes #29 (?) .. apparently a non issue 
JoschD: Fixes #30

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dcff994..28295e9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,16 @@
 # Generic-Parser Changelog
+## Version 1.0.7
+ * fixed crash before proper error message for invalid choice of non-string
+ objects. 
+ * fixed: Paths can be handled by `save_options_to_config` (converted to strings)
+ * fixed: Quotes around Paths are stripped from config files on reading
+ * fixes: Strings with linebreaks are now written properly with `save_options_to_config`
+ * fixed `None` write and read in config files.
+ * added tests for list-lengths of default parameters.
+ * improved `create_parameter_help` function.
+ * some documentation fixed/additions.
+ * switched to github actions for CI.
+ 
 
 ## Version 1.0.6
  * Fix KeyError for unknown flags
diff --git a/README.md b/README.md
index fa480f9..b97564d 100644
--- a/README.md
+++ b/README.md
@@ -112,7 +112,6 @@ For a detailed description check [the documentation](https://pylhc.github.io/gen
 
 ## Authors
 
-* **Josch** - [JoschD](https://github.com/JoschD)
 * **pyLHC/OMC-Team** - *Working Group* - [pyLHC](https://github.com/orgs/pylhc/teams/omc-team)
 
 
diff --git a/doc/conf.py b/doc/conf.py
index 611fe39..06681a1 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -183,8 +183,4 @@ texinfo_documents = [
 
 # Add here all modules to be mocked up. When the dependencies are not met
 # at building time. Here used to have PyQT mocked.
-# autodoc_mock_imports = ['PyQt5', 'PyQt5.QtGui', 'PyQt5.QtCore', 'PyQt5.QtWidgets',
-#                         "matplotlib.backends.backend_qt5agg",
-#                         "numpy.random",  # caused an error for unused footnote in ADDbpmerror
-#                         ]
-
+autodoc_mock_imports = []
diff --git a/generic_parser/dict_parser.py b/generic_parser/dict_parser.py
index 0e4e819..5c8a9ff 100644
--- a/generic_parser/dict_parser.py
+++ b/generic_parser/dict_parser.py
@@ -5,6 +5,7 @@ Dictionary Parser
 import argparse
 import copy
 import logging
+from pathlib import Path
 
 from generic_parser.tools import DotDict, _TC
 
@@ -130,8 +131,8 @@ class DictParser(object):
                                     f"Help: {param.help:s}")
 
         elif param.choices and opt not in param.choices:
-            raise ArgumentError(f"'{key:s}' needs to be one of '{param.choices:}', "
-                                f"instead it was {opt:s}.\n"
+            raise ArgumentError(f"'{key:s}' needs to be one of '{param.choices}', "
+                                f"instead it was {opt}.\n"
                                 f"Help: {param.help:s}")
         return opt
 
@@ -339,16 +340,20 @@ class DictParser(object):
                 raise ArgumentError(f"Could not evaluate argument '{name:s}', unknown '{item:s}'")
 
         def eval_type(my_type, item):
-            if issubclass(my_type, str):
+            if issubclass(my_type, (str, Path)):
                 return my_type(item.strip("\'\""))
+
             if issubclass(my_type, bool):
                 return bool(eval(item))
-            else:
-                return my_type(item)
+
+            return my_type(item)
 
         out = {}
         for name, value in items:
-            if name in self.dictionary:
+            if value == '':  # only needed if save_dict allows `key=`
+                out[name] = None  # type doesn't matter
+
+            elif name in self.dictionary:
                 arg = self.dictionary[name]
                 if arg.type == list:
                     value = evaluate(name, value)
@@ -360,6 +365,7 @@ class DictParser(object):
                 else:
                     value = evaluate(name, value)
                 out[name] = value
+
             else:
                 # could check self.strict here, but result is passed to get checked anyway
                 out[name] = evaluate(name, value)
@@ -419,6 +425,16 @@ class Parameter(object):
                 raise ParameterError(f"Parameter '{self.name:s}': " +
                                      "'type' needs to be 'list' if 'nargs' is given.")
 
+            if self.default is not None:  # default-type is checked above as self.type needs to be present
+                if (self.nargs == argparse.ONE_OR_MORE) and not len(self.default):
+                    raise ParameterError(f"Parameter '{self.name:s}': " +
+                                         f"Empty list as default not allowed for nargs='{self.nargs}'.")
+
+                if isinstance(self.nargs, int) and not (self.nargs == len(self.default)):
+                    raise ParameterError(f"Parameter '{self.name:s}': " +
+                                         f"Default value has wrong length (={len(self.default):d}) "
+                                         f"for given nargs={self.nargs:d}.")
+
         if self.choices:
             try:
                 [choice for choice in self.choices]
diff --git a/generic_parser/entrypoint_parser.py b/generic_parser/entrypoint_parser.py
index 9856490..7ee5916 100644
--- a/generic_parser/entrypoint_parser.py
+++ b/generic_parser/entrypoint_parser.py
@@ -106,7 +106,7 @@ Example with list of dictionaries:
 .. code-block:: python
 
     args = [
-        "dict(
+        dict(
             name="accel",
             flags=["-a", "--accel"],
             help="Which accelerator?",
@@ -126,6 +126,45 @@ The **strict** option changes the behaviour for unknown parameters:
 Hence a wrapped function with ``strict=True`` must accept one input, with ``strict=False`` two.
 Default: ``False``
 
+
+Arguments:
+++++++++++++++++++++++++
+
+Arguments to a decorated function can be passed in multiple ways.
+Let's take the decorated example function::
+
+    @entrypoint(EntryPointParameters({"foo": {}, "bar": {}}), strict=True)
+    def entry_function(opt):
+        print(opt)
+
+
+Then the different ways to call it are.
+
+from python::
+
+    entry_function(foo='mark', bar='twain')  # keyword args
+
+    entry_function(dict(foo='mark', bar='twain'))  # dictionary
+    entry_function(entry_dict=dict(foo='mark', bar='twain'))  # dictionary
+
+    entry_function(['--foo', 'mark', '--bar', 'twain'])  # commandline args
+
+    entry_function(entry_cfg=Path('config.ini'))  # config file
+    entry_function(entry_cfg=Path('config.ini'), section='section')  # '[section]' in config file
+
+
+    entry_function(entry_json=Path('config.json'))  # json file
+    entry_function(entry_json=Path('config.json'), section='section')  # '[section]' in json file
+
+
+
+as commandline arguments from a `script.py` that calls ``entry_function()``::
+
+    python script.py --foo mark --bar twain
+    python script.py --entry_cfg config.ini
+    python script.py --entry_cfg config.ini --section section
+
+
 """
 import copy
 import json
@@ -134,6 +173,8 @@ from argparse import ArgumentParser
 from configparser import ConfigParser
 from inspect import getfullargspec
 from functools import wraps
+from pathlib import Path
+from textwrap import wrap
 
 from generic_parser.tools import DotDict, silence, unformatted_console_logging
 from generic_parser.dict_parser import ParameterError, ArgumentError, DictParser
@@ -159,15 +200,17 @@ class EntryPoint(object):
         # add argument dictionary to EntryPoint
         self.remainder = None
         self.parameter = dict2list_param(parameter)
-        self._check_parameter()
+        self._check_parameter()  # just simple checks for structure
 
         # add config-argparser
         self.configarg = self._create_config_argument()
 
         # create parsers from parameter
+        # this also ensures that the parameters are correctly defined,
+        # by tests in argparser and in Parameter(),
+        # which is used in dict_parser -> add parameter
         self.argparse = self._create_argument_parser()
-        self.dictparse = self._create_dict_parser()
-        self.configparse = self._create_config_parser()
+        self.dictparse = self._create_dict_parser()  # also used for configfiles
 
     def parse(self, *args, **kwargs):
         """ Parse whatever input parameter come.
@@ -220,13 +263,13 @@ class EntryPoint(object):
     def _create_argument_parser(self):
         """ Creates the ArgumentParser from parameter. """
         parser = ArgumentParser()
-        parser = add_params_to_generic(parser, self.parameter)
+        parser = _add_params_to_argument_parser(parser, self.parameter)
         return parser
 
     def _create_dict_parser(self):
         """ Creates the DictParser from parameter. """
         parser = DictParser(strict=self.strict)
-        parser = add_params_to_generic(parser, self.parameter)
+        parser = _add_params_to_dict_parser(parser, self.parameter)
         return parser
 
     def _create_config_parser(self):
@@ -336,7 +379,8 @@ class EntryPoint(object):
 
     def _read_config(self, cfgfile_path, section=None):
         """ Get content from config file"""
-        cfgparse = self.configparse
+        # create new config parser, as it keeps defaults between files
+        cfgparse = self._create_config_parser()
 
         with open(cfgfile_path) as config_file:
             cfgparse.read_file(config_file)
@@ -352,9 +396,6 @@ class EntryPoint(object):
                                     " Please specify one!")
 
         items = cfgparse.items(section)
-
-        # clear the config parser, it keeps defaults between files
-        self.configparse = self._create_config_parser()
         return items
 
 
@@ -435,57 +476,59 @@ class EntryPointParameters(DotDict):
 
     def help(self):
         """ Prints current help. Usable to paste into docstrings. """
-        optional_param = ""
-        required_param = ""
+        optional_params = []
+        required_params = []
+        space = " " * 4
 
         for name in sorted(self.keys()):
-            item_str = ""
             item = self[name]
             try:
-                name_type = f"- **{name}** *({item['type'].__name__})*"
+                name_and_type = f"- **{name}** *({item['type'].__name__})*:\n\n"
             except KeyError:
-                name_type = f"- **{name}**"
+                name_and_type = f"- **{name}**:\n\n"
 
             try:
-                item_str += f"{name_type}: {item['help']}"
+                help_str = f"{item['help']}"
             except KeyError:
-                item_str += f"{name_type}: -Help not available- "
+                help_str = "-No info available-"
 
-            item_str += "\n"
-            space = " " * 2
+            help_str = "\n".join([f"{space}{line}" for line in wrap(help_str, 70)])
+            help_str = f"{help_str}\n\n"
 
             try:
-                item_str += f"\n{space}Flags: **{item['flags']}**"
+                flags = f"{space}flags: **{item['flags']}**\n\n"
             except KeyError:
-                pass
+                flags = ''
 
             try:
-                item_str += f"\n{space}Choices: ``{item['choices']}``"
+                choices = f"{space}choices: ``{item['choices']}``\n\n"
             except KeyError:
-                pass
+                choices = ''
 
             try:
-                item_str += f"\n{space}Default: ``{item['default']}``"
+                default = f"{space}default: ``{item['default']}``\n\n"
             except KeyError:
-                pass
+                default = ''
 
             try:
-                item_str += f"\n{space}Action: ``{item['action']}``"
+                action = f"{space}action: ``{item['action']}``\n\n"
             except KeyError:
-                pass
+                action = ''
+
+            item_str = f"{name_and_type}{help_str}{flags}{choices}{default}{action}"
 
             if item.get("required", False):
-                required_param += item_str + "\n"
+                required_params.append(item_str)
             else:
-                optional_param += item_str + "\n"
+                optional_params.append(item_str)
 
-        if required_param:
-            LOG.info("*--Required--*")
-            LOG.info(required_param)
+        if required_params:
+            LOG.info("*--Required--*\n")
+            LOG.info("\n".join(required_params))
 
-        if optional_param:
-            LOG.info("*--Optional--*")
-            LOG.info(optional_param)
+        if optional_params:
+            LOG.info("*--Optional--*\n")
+            LOG.info("\n".join(optional_params))
 
 
 # Public Helpers ###############################################################
@@ -547,51 +590,71 @@ def add_to_arguments(args, entry_params=None, **kwargs):
     return args
 
 
+# parameter adders ---
+
 def add_params_to_generic(parser, params):
     """ Adds entry-point style parameter to either
     ArgumentParser, DictParser or EntryPointParameters
     """
-    params = copy.deepcopy(params)
-    params = dict2list_param(params)
-
     if isinstance(parser, EntryPointParameters):
-        for param in params:
-            parser.add_parameter(**param)
+        parser = _add_params_to_entrypoint_parameters(parser, params)
 
     elif isinstance(parser, ArgumentParser):
-        for param in params:
-            param["dest"] = param.pop("name", None)
-            flags = param.pop("flags", None)
-            if flags is None:
-                parser.add_argument(**param)
-            else:
-                if isinstance(flags, str):
-                    flags = [flags]
-                parser.add_argument(*flags, **param)
+        parser = _add_params_to_argument_parser(parser, params)
 
     elif isinstance(parser, DictParser):
-        for param in params:
-            if "nargs" in param:
-                param["subtype"] = param.get("type", None)
-                param["type"] = list
-
-            if "action" in param:
-                if param["action"] in ("store_true", "store_false"):
-                    param["type"] = bool
-                    param["default"] = not param["action"][6] == "t"
-                else:
-                    raise ParameterError(f"Action '{param['action']:s}' not allowed in EntryPoint")
-                param.pop("action")
-
-            param.pop("flags", None)
-
-            name = param.pop("name")
-            parser.add_parameter(name, **param)
+        parser = _add_params_to_dict_parser(parser, params)
+
     else:
         raise TypeError("Parser not recognised.")
     return parser
 
 
+def _add_params_to_entrypoint_parameters(entry_parser, params):
+    params = dict2list_param(copy.deepcopy(params))
+    for param in params:
+        entry_parser.add_parameter(**param)
+    return entry_parser
+
+
+def _add_params_to_dict_parser(dict_parser, params):
+    params = dict2list_param(copy.deepcopy(params))
+    for param in params:
+        if "nargs" in param:
+            param["subtype"] = param.get("type", None)
+            param["type"] = list
+
+        if "action" in param:
+            if param["action"] in ("store_true", "store_false"):
+                param["type"] = bool
+                param["default"] = not param["action"][6] == "t"
+            else:
+                raise ParameterError(f"Action '{param['action']:s}' not allowed in EntryPoint")
+            param.pop("action")
+
+        param.pop("flags", None)
+
+        name = param.pop("name")
+        dict_parser.add_parameter(name, **param)
+    return dict_parser
+
+
+def _add_params_to_argument_parser(arg_parser, params):
+    params = dict2list_param(copy.deepcopy(params))
+    for param in params:
+        param["dest"] = param.pop("name", None)
+        flags = param.pop("flags", None)
+        if flags is None:
+            arg_parser.add_argument(**param)
+        else:
+            if isinstance(flags, str):
+                flags = [flags]
+            arg_parser.add_argument(*flags, **param)
+    return arg_parser
+
+# ---
+
+
 def split_arguments(args, *param_list):
     """ Divide remaining arguments into a list of argument-dicts,
         fitting to the params in param_list.
@@ -616,7 +679,7 @@ def split_arguments(args, *param_list):
         # (as I don't know how to handle flags properly)
         for params in param_list:
             parser = argparse.ArgumentParser()
-            parser = add_params_to_generic(parser, params)
+            parser = _add_params_to_argument_parser(parser, params)
             this_args, args = parser.parse_known_args(args)
             split_args.append(DotDict(this_args.__dict__))
         split_args.append(args)
@@ -668,20 +731,24 @@ def save_options_to_config(filepath, opt, unknown=None):
         unknown: unknown options (only safe for non-commandline parameters)
 
     """
-    def _check_for_string(key, value):
-        if isinstance(value, str):
+    def _to_key_value_str(key, value):
+        if value is None:
+            value = ''  # defined as empty (see dict_parser._convert_config_items)
+        elif isinstance(value, (str, Path)):
             value = f'"{value}"'
-        return f"{key}={value}\n"
+            if "\n" in value:
+                value = value.replace("\n", "\n    ")  # spaces in new line indicate continuation
+        return f"{key} = {value}\n"
 
     lines = "[DEFAULT]\n"
     for o in opt:
-        lines += _check_for_string(o, opt[o])
+        lines += _to_key_value_str(o, opt[o])
 
-    if unknown is not None:
+    if unknown is not None and len(unknown):
         lines += "; Unknown options --------------------------\n"
         if isinstance(unknown, dict):
             for o in unknown:
-                lines += _check_for_string(o, unknown[o])
+                lines += _to_key_value_str(o, unknown[o])
         else:
             lines += f"; {' '.join(unknown)}\n"
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..5906e6c
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,32 @@
+"""
+Additional tools for pytests.
+The name ``conftest.py`` is chosen as it is used by pytest.
+Fixtures defined in here are discovered by all tests automatically.
+
+See also https://stackoverflow.com/a/34520971 .
+"""
+import shutil
+import sys
+from contextlib import contextmanager
+from pathlib import Path
+
+import pytest
+
+
+@contextmanager
+def cli_args(*args, **kwargs):
+    """ Provides context to run an entrypoint like with commandline args.
+    Arguments are restored after context.
+
+    Args:
+        The Commandline args (excluding the script name)
+
+    Keyword Args:
+        script: script-name. Used as first commandline-arg.
+                Otherwise it's 'somescript.py'
+    """
+    script = kwargs.get('script', 'somescript.py')
+    args_save = sys.argv.copy()
+    sys.argv = [script] + list(args)
+    yield
+    sys.argv = args_save
diff --git a/tests/test_dict_parser.py b/tests/test_dict_parser.py
index a009712..5028ff1 100644
--- a/tests/test_dict_parser.py
+++ b/tests/test_dict_parser.py
@@ -4,7 +4,7 @@ import pytest
 import sys
 import logging
 
-from generic_parser.dict_parser import ParameterError, Parameter, DictParser
+from generic_parser.dict_parser import ParameterError, Parameter, DictParser, ArgumentError
 from generic_parser.tools import TempStringLogger
 
 
@@ -31,6 +31,13 @@ def test_add_param_loc():
     assert parser.dictionary["sub"]["suub"]["suuub"]["test"].name == "test"
     assert parser.dictionary["sub"]["suub"]["suuub"]["test"].default == "def"
 
+def test_non_string_outside_choices():
+    parser = DictParser()
+    parser.add_parameter(Parameter("test", choices=[1, 2]))
+    with pytest.raises(ArgumentError):
+        parser.parse_arguments({'test': {'param': 4},})
+
+
 
 def test_add_param_loc2():
     parser = DictParser()
diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py
index d20d646..bd18c92 100644
--- a/tests/test_entrypoint.py
+++ b/tests/test_entrypoint.py
@@ -1,9 +1,9 @@
 import logging
-import os
 import sys
-import tempfile
+from pathlib import Path
 
 import pytest
+from tests.conftest import cli_args
 
 from generic_parser.dict_parser import ParameterError, ArgumentError
 from generic_parser.entry_datatypes import get_multi_class, DictAsString, BoolOrString, BoolOrList
@@ -14,7 +14,9 @@ from generic_parser.entrypoint_parser import (EntryPointParameters,
                                               )
 from generic_parser.tools import silence, print_dict_tree, TempStringLogger
 
+
 LOG = logging.getLogger(__name__)
+DEBUG = False
 
 
 # Tests ########################################################################
@@ -81,6 +83,16 @@ def test_choices_not_iterable():
                      }])
 
 
+def test_empty_list_default_for_nargs_plus():
+    with pytest.raises(ParameterError):
+        EntryPoint([{"name": "test", "nargs": "+", "default": []}])
+
+
+def test_wrong_length_default_for_nargs():
+    with pytest.raises(ParameterError):
+        EntryPoint([{"name": "test", "nargs": 2, "default": [1, 2, 3]}])
+
+
 def test_missing_flag_replaced_by_name():
     ep = EntryPoint([{"name": "test"}])
 
@@ -147,27 +159,37 @@ def test_as_string():
     assert len(unknown) > 0
 
 
-def test_as_config():
-    with tempfile.TemporaryDirectory() as cwd:
-        cfg_file = os.path.join(cwd, "config.ini")
-        with open(cfg_file, "w") as f:
-            f.write("\n".join([
-                "[Section]",
-                "name = 'myname'",
-                "int = 3",
-                "list = [4, 5, 6]",
-                "unknown = 'other'",
-            ]))
-
-        # test config as kwarg
-        opt1, unknown1 = paramtest_function(
-            entry_cfg=cfg_file, section="Section"
-        )
-
-        # test config as commandline args
-        opt2, unknown2 = paramtest_function(
-            ["--entry_cfg", cfg_file, "--section", "Section"]
-        )
+def test_as_argv():  # almost identical to above
+    with cli_args("--name", "myname", "--int", "3", "--list", "4", "5", "6",
+                  "--other"):
+        opt, unknown = paramtest_function()
+        assert opt.name == "myname"
+        assert opt.int == 3
+        assert len(opt.list) == 3
+        assert opt.list[1] == 5
+        assert len(unknown) > 0
+
+
+def test_as_config(tmp_path):
+    cfg_file = tmp_path / "config.ini"
+    with open(cfg_file, "w") as f:
+        f.write("\n".join([
+            "[Section]",
+            "name = 'myname'",
+            "int = 3",
+            "list = [4, 5, 6]",
+            "unknown = 'other'",
+        ]))
+
+    # test config as kwarg
+    opt1, unknown1 = paramtest_function(
+        entry_cfg=cfg_file, section="Section"
+    )
+
+    # test config as commandline args
+    opt2, unknown2 = paramtest_function(
+        ["--entry_cfg", str(cfg_file), "--section", "Section"]
+    )
 
     assert opt1.name == "myname"
     assert opt1.int == 3
@@ -213,9 +235,48 @@ def test_not_enough_length():
         some_function(accel="LHCB1", anint=3, alist=[])
 
 
-# Config Saver Test
+def test_optional_parameter_no_default_accepts_none():
+    @entrypoint(dict(foo=dict(required=False)), strict=True)
+    def fun(opt):
+        return opt
+
+    opt = fun(foo=None)
+    assert opt.foo is None
+
+
+def test_optional_list_parameter_no_default_accepts_none():
+    @entrypoint(dict(foo=dict(required=False, nargs=3)), strict=True)
+    def fun(opt):
+        return opt
+
+    opt = fun(foo=None)
+    assert opt.foo is None
+
+
+def test_optional_parameter_default_accepts_none():
+    @entrypoint(dict(foo=dict(required=False, default='test')), strict=True)
+    def fun(opt):
+        return opt
+
+    opt = fun({})
+    assert opt.foo == 'test'
+
+    opt = fun(foo=None)
+    assert opt.foo is None
+
+
+def test_required_parameter_does_not_accept_none():
+    @entrypoint(dict(foo=dict(required=True)), strict=True)
+    def fun(opt):
+        return opt
+
+    with pytest.raises(ArgumentError):
+        fun(foo=None)
 
-def test_save_options():
+
+# Test Config read-write -------------------------------------------------------
+
+def test_save_options(tmp_path):
     opt, unknown = paramtest_function(
         name="myname",
         int=3,
@@ -223,39 +284,130 @@ def test_save_options():
         unknown="myfinalargument",
         unknoown=10,
     )
-    with tempfile.TemporaryDirectory() as cwd:
-        cfg_file = os.path.join(cwd, "config.ini")
-        save_options_to_config(cfg_file, opt, unknown)
-        opt_load, unknown_load = paramtest_function(entry_cfg=cfg_file)
+    cfg_file = tmp_path / "config.ini"
+    save_options_to_config(cfg_file, opt, unknown)
+    opt_load, unknown_load = paramtest_function(entry_cfg=cfg_file)
 
-    _assert_dicts(opt, opt_load)
-    _assert_dicts(unknown, unknown_load)
+    _assert_dicts_equal(opt, opt_load)
+    _assert_dicts_equal(unknown, unknown_load)
 
 
-def test_save_cli_options():
+def test_save_cli_options_cfg(tmp_path):
     opt, unknown = paramtest_function(
         ["--name", "myname",
          "--int", "3",
          "--list", "4", "5", "6",
          "--other"]
     )
-    with tempfile.TemporaryDirectory() as cwd:
-        cfg_file = os.path.join(cwd, "config.ini")
-        save_options_to_config(cfg_file, opt, unknown)
-        opt_load, unknown_load = paramtest_function(entry_cfg=cfg_file)
+    cfg_file = tmp_path / "config.ini"
+    save_options_to_config(cfg_file, opt, unknown)
+    opt_load, unknown_load = paramtest_function(entry_cfg=cfg_file)
+
+    with open(cfg_file, 'r') as f:
+        content = f.read()
+    assert 'Unknown' in content
+    assert '--other' in content
 
-    _assert_dicts(opt, opt_load)
+    _assert_dicts_equal(opt, opt_load)
     assert len(unknown_load) == 0
 
 
-def _assert_dicts(d1, d2):
-    for key in d1:
-        assert d1[key] == d2[key]
-    assert len(d2) == len(d1)
+def test_save_and_cfg_load_with_none(tmp_path):
+    # use cli_args in case we run in test-wrapper (e.g. pycharm)
+    with cli_args():  # name, int, list = None
+        opt, unknown = paramtest_function()
+
+    cfg_file = tmp_path / "config.ini"
+    save_options_to_config(cfg_file, opt, unknown)
+    opt_load, unknown_load = paramtest_function(entry_cfg=cfg_file)
+
+    _assert_dicts_equal(opt, opt_load)
+    assert len(unknown) == 0
+    assert len(unknown_load) == 0
+    assert all([val is None for val in opt.values()])
+
 
+def test_save_and_load_cfg_with_none_explicit(tmp_path):
+    opt, unknown = paramtest_function(name=None, int=None, list=None)
 
-# Test Special Datatypes
+    cfg_file = tmp_path / "config.ini"
+    save_options_to_config(cfg_file, opt, unknown)
+    opt_load, unknown_load = paramtest_function(entry_cfg=cfg_file)
 
+    _assert_dicts_equal(opt, opt_load)
+    assert len(unknown) == 0
+    assert len(unknown_load) == 0
+    assert all([val is None for val in opt.values()])
+
+
+def test_string_cfg(tmp_path):
+    @entrypoint(EntryPointParameters(dict(name={'type': str})), strict=True)
+    def fun(opt):
+        return opt
+
+    cfg_quotes = tmp_path / "config_quotes.ini"
+    with open(cfg_quotes, "w") as f:
+        f.write("[Section]\nname = 'My String with Spaces'")
+
+    cfg_doublequotes = tmp_path / "config_doublequotes.ini"
+    with open(cfg_doublequotes, "w") as f:
+        f.write('[Section]\nname = "My String with Spaces"')
+
+    cfg_noquotes = tmp_path / "config_noquotes.ini"
+    with open(cfg_noquotes, "w") as f:
+        f.write('[Section]\nname = My String with Spaces')
+
+    opt_quotes = fun(entry_cfg=cfg_quotes)
+    opt_doublequotes = fun(entry_cfg=cfg_doublequotes)
+    opt_noquotes = fun(entry_cfg=cfg_noquotes)
+
+    assert opt_quotes.name == opt_doublequotes.name
+    assert opt_quotes.name == opt_noquotes.name
+
+
+def test_string_with_break_cfg(tmp_path):
+    @entrypoint(EntryPointParameters(dict(name={'type': str})), strict=True)
+    def fun(opt):
+        return opt
+
+    opt = fun(name="this is\nmystring")
+
+    cfg_file = tmp_path / "config.ini"
+    save_options_to_config(cfg_file, opt)
+    opt_load = fun(entry_cfg=cfg_file)
+
+    assert opt_load.name == opt.name
+
+
+def test_path_cfg(tmp_path):
+    @entrypoint(EntryPointParameters(dict(path={'type': Path})), strict=True)
+    def fun(opt):
+        return opt
+
+    cfg_file = tmp_path / "config.ini"
+    opt = fun(path=tmp_path)
+
+    save_options_to_config(cfg_file, opt)
+
+    opt_load = fun(entry_cfg=cfg_file)
+    _assert_dicts_equal(opt, opt_load)
+
+
+def test_list_cfg(tmp_path):
+    @entrypoint(EntryPointParameters(dict(lst={'type': int, 'nargs': "*"})), strict=True)
+    def fun(opt):
+        return opt
+
+    cfg_file = tmp_path / "config.ini"
+    opt = fun(lst=[1, 2, 3, 4])
+
+    save_options_to_config(cfg_file, opt)
+
+    opt_load = fun(entry_cfg=cfg_file)
+    _assert_dicts_equal(opt, opt_load)
+
+
+# Special Classes --------------------------------------------------------------
 
 def test_multiclass_class():
     float_str = get_multi_class(float, str)
@@ -340,7 +492,7 @@ def test_dict_as_string():
     assert opt.dict['str'] == 'hello'
 
 
-def test_bool_or_str():
+def test_bool_or_str(tmp_path):
     @entrypoint([dict(flags="--bos", name="bos", type=BoolOrString)], strict=True)
     def fun(opt):
         return opt
@@ -360,25 +512,23 @@ def test_bool_or_str():
     opt = fun(["--bos", "myString"])
     assert opt.bos == "myString"
 
-    with tempfile.TemporaryDirectory() as cwd:
-        cfg_file = os.path.join(cwd, "bos.ini")
-        with open(cfg_file, "w") as f:
-            f.write("[Section]\nbos = 'myString'")
-        opt = fun(entry_cfg=cfg_file)
+    cfg_file = tmp_path / "bos.ini"
+    with open(cfg_file, "w") as f:
+        f.write("[Section]\nbos = 'myString'")
+    opt = fun(entry_cfg=cfg_file)
     assert opt.bos == "myString"
 
 
-def test_bool_or_str_cfg():
+def test_bool_or_str_cfg(tmp_path):
     @entrypoint([dict(flags="--bos1", name="bos1", type=BoolOrString),
                  dict(flags="--bos2", name="bos2", type=BoolOrString)], strict=True)
     def fun(opt):
         return opt
 
-    with tempfile.TemporaryDirectory() as cwd:
-        cfg_file = os.path.join(cwd, "bos.ini")
-        with open(cfg_file, "w") as f:
-            f.write("[Section]\nbos1 = 'myString'\nbos2 = True")
-        opt = fun(entry_cfg=cfg_file)
+    cfg_file = tmp_path / "bos.ini"
+    with open(cfg_file, "w") as f:
+        f.write("[Section]\nbos1 = 'myString'\nbos2 = True")
+    opt = fun(entry_cfg=cfg_file)
     assert opt.bos1 == 'myString'
     assert opt.bos2 is True
 
@@ -404,17 +554,16 @@ def test_bool_or_list():
     assert opt.bol is True
 
 
-def test_bool_or_list_cfg():
+def test_bool_or_list_cfg(tmp_path):
     @entrypoint([dict(flags="--bol1", name="bol1", type=BoolOrList),
                  dict(flags="--bol2", name="bol2", type=BoolOrList)], strict=True)
     def fun(opt):
         return opt
 
-    with tempfile.TemporaryDirectory() as cwd:
-        cfg_file = os.path.join(cwd, "bol.ini")
-        with open(cfg_file, "w") as f:
-            f.write("[Section]\nbol1 = 1,2\nbol2 = True")
-        opt = fun(entry_cfg=cfg_file)
+    cfg_file = tmp_path / "bol.ini"
+    with open(cfg_file, "w") as f:
+        f.write("[Section]\nbol1 = 1,2\nbol2 = True")
+    opt = fun(entry_cfg=cfg_file)
     assert opt.bol1 == [1, 2]
     assert opt.bol2 is True
 
@@ -522,7 +671,7 @@ def get_other_params():
     return args
 
 
-# Example Wrapped Functions ####################################################
+# Example Wrapped Functions ----------------------------------------------------
 
 
 @entrypoint(get_params())
@@ -543,3 +692,12 @@ def strict_function(options):
 @entrypoint(get_testing_params())
 def paramtest_function(opt, unknown):
     return opt, unknown
+
+
+# Other ------------------------------------------------------------------------
+
+
+def _assert_dicts_equal(d1, d2):
+    for key in d1:
+        assert d1[key] == d2[key]
+    assert len(d2) == len(d1)