simpleconfigfinder

This is a small package with utility functions to find and handle configuration files that are stored upstream from the code.

configfinder

ConfigNotFound

Bases: Exception

will be raised when the given keys for the sub-configuration do not exist in the configuration file

Source code in src\simpleconfigfinder\configfinder.py
13
14
15
16
class ConfigNotFound(Exception):
    """will be raised when the given keys for the sub-configuration do not exist in the configuration file"""

    pass

combine_dictionaries

combine_dictionaries(dict_a, dict_b)

combine two dictionaries on a granular level. The entries of dict_a always have priority over entries of dict_b.

Caution

this function modifies the original dicitionaries. If this matters, use:

from copy import deepcopy
combine_dictionaries(dict_a, deepcopy(dict_b))
Parameters:
  • dict_a (Any) –

    Reference dictionary

  • dict_b (Any) –

    Another dictionary from where the key will be added

Returns:
  • Any

    Combined dictionary of both dict_a and dict_b. Values from dict_a have priority over dict_b. If some values are dictionaries, they will be combined recursively.

Examples:

>>> combine_dictionaries({"a" : 1}, {"b" : 2})
{'b': 2, 'a': 1}

>>> combine_dictionaries({"a" : {"c" : 3}}, {"a" : {"c" : 4}})
{'a': {'c': 3}}

>>> combine_dictionaries({"a" : {"c" : 3}}, {"a" : {"e" : 5}})
{'a': {'e': 5, 'c': 3}}

>>> some_dictionary = {"a" : 1} # to show changes
>>> combine_dictionaries({"b" : 2}, some_dictionary)
{'a': 1, 'b': 2}
>>> some_dictionary # ATTENTION: modified
{'a': 1, 'b': 2}

>>> from copy import deepcopy
>>> some_dictionary = {"a" : 1}
>>> combine_dictionaries({"b" : 2}, deepcopy(some_dictionary))
{'a': 1, 'b': 2}
>>> some_dictionary
{'a': 1}
Source code in src\simpleconfigfinder\configfinder.py
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
def combine_dictionaries(dict_a: Any, dict_b: Any) -> Any:
    """combine two dictionaries on a granular level. The entries of `dict_a` always have priority over entries of `dict_b`.

    !!! caution
        this function modifies the original dicitionaries. If this matters, use:

            from copy import deepcopy
            combine_dictionaries(dict_a, deepcopy(dict_b))

    Args:
        dict_a: Reference dictionary
        dict_b: Another dictionary from where the key will be added

    Returns:
        Combined dictionary of both dict_a and dict_b.   Values from dict_a have priority over dict_b.        If some values are dictionaries, they will be combined recursively.

    <!--note that the blank line behind Example needs to be there. There are two options.
    Either the examples are rendered properly (with integrated drop-down)
    Or the Doctests are rendered properly-->

    Examples:

        >>> combine_dictionaries({"a" : 1}, {"b" : 2})
        {'b': 2, 'a': 1}

        >>> combine_dictionaries({"a" : {"c" : 3}}, {"a" : {"c" : 4}})
        {'a': {'c': 3}}

        >>> combine_dictionaries({"a" : {"c" : 3}}, {"a" : {"e" : 5}})
        {'a': {'e': 5, 'c': 3}}

        >>> some_dictionary = {"a" : 1} # to show changes
        >>> combine_dictionaries({"b" : 2}, some_dictionary)
        {'a': 1, 'b': 2}
        >>> some_dictionary # ATTENTION: modified
        {'a': 1, 'b': 2}

        >>> from copy import deepcopy
        >>> some_dictionary = {"a" : 1}
        >>> combine_dictionaries({"b" : 2}, deepcopy(some_dictionary))
        {'a': 1, 'b': 2}
        >>> some_dictionary
        {'a': 1}
    """

    def check_instance(db):
        return isinstance(db, collections.abc.Mapping)

    # dict a not a dictionary -> dict_a over-writes dict_b
    if not check_instance(dict_a):
        return dict_a

    # dict a not a dictionary -> dict_a over-writes dict_b
    if not check_instance(dict_b):
        return dict_a

    # both are dictionaries -> recursively combine
    for k, v in dict_a.items():
        if check_instance(v):
            dict_b[k] = combine_dictionaries(v, dict_b.get(k, {}))
        else:
            dict_b[k] = v

    # add missing keys as they wil not be passed by the loop
    missing_keys = {k: v for k, v in dict_b.items() if k not in dict_a}
    dict_b.update(missing_keys)

    return dict_b

config_finder

config_finder(
    config_fname,
    sub_config_keys=None,
    raise_error=True,
    additional_readers=None,
    strategy="__main__",
)

goes upstream from the currently executed file and finds the file config_fname and returns the sub_config_keys

Starts with the directory of the currently executed file (__main__.__file__) and searches upstream. For Jupyter Notebooks, os.path.abspath("") will be returned instead.

Examples:

When configurations to the pyproject.toml like

[tool.some_tool.default_config]
some_key = "some_value"

Then you can get these values via

>>> config_finder("pyproject.toml", ["tool", "some_tool", "default_config"])
{'some_key': 'some_value'}
Parameters:
  • config_fname (str | PurePath) –

    The name of the configuration file as toml or json.

  • sub_config_keys (Optional[list[str]], default: None ) –

    A list of the keys to identify the sub-configuration. returns the full config if nothing is provided.

  • raise_error

    if errors will be raised in case any of the files are not found

  • additional_readers (Optional[Dict[str, Callable[[Any], Dict[str, Any]]]], default: None ) –

    dictionary to define for file extensions which readers will be used (e.g. for yaml via {"yaml": yaml.safe_load}). In general this works for any function that can take a file name as string or PurePath and return a dictionary. For a code example see Other Readers

  • strategy (str, default: '__main__' ) –

    will be passed to find_file to determine the initial file

Returns:
  • Dict[str, Any]

    "filtered" Dictionary, where teh sub_config_keys where already applied. I.e. config[sub_config_keys[0]][sub_config_keys[1]]...

Source code in src\simpleconfigfinder\configfinder.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
def config_finder(
    config_fname: str | PurePath,
    sub_config_keys: Optional[list[str]] = None,
    raise_error=True,
    additional_readers: Optional[Dict[str, Callable[[Any], Dict[str, Any]]]] = None,
    strategy: str = "__main__",
) -> Dict[str, Any]:
    """goes upstream from the currently executed file and finds the file `config_fname` and returns the `sub_config_keys`

    Starts with the directory of the currently executed file (`__main__.__file__`) and searches upstream.
    For Jupyter Notebooks, `os.path.abspath("")` will be returned instead.

    Examples:

    When configurations to the pyproject.toml like

        [tool.some_tool.default_config]
        some_key = "some_value"

    Then you can get these values via

        >>> config_finder("pyproject.toml", ["tool", "some_tool", "default_config"])
        {'some_key': 'some_value'}

    Args:
        config_fname: The name of the configuration file as toml or json.
        sub_config_keys: A list of the keys to identify the sub-configuration. returns the full config if nothing is provided.
        raise_error: if errors will be raised in case any of the files are not found
        additional_readers: dictionary to define for file extensions which readers will be used (e.g. for yaml via  {"yaml": yaml.safe_load}). In general this works for any function that can take a file name as string or PurePath and return a dictionary. For a code example see [Other Readers](index.md#other-readers)
        strategy: will be passed to `find_file` to determine the initial file

    Returns:
        "filtered" Dictionary, where teh sub_config_keys where already applied. I.e. config[sub_config_keys[0]][sub_config_keys[1]]...
    """

    # cut the leading dot
    extension = Path(config_fname).suffix[1:].lower()

    def ini_reader(file) -> Dict:
        """small unitility function to read ini files"""
        cfg = ConfigParser()
        cfg.read(file)
        return configparser_to_dict(cfg)

    reader_dictionary = {"toml": tomllib.load, "json": json.load, "ini": ini_reader}
    if additional_readers:
        reader_dictionary.update(additional_readers)

    if extension not in reader_dictionary:
        raise NotImplementedError(
            f"config finder not implmeneted for '{extension}'. Use any of '{reader_dictionary.keys()}'"
        )

    reader = reader_dictionary[extension]

    try:
        fname = find_file(config_fname, strategy=strategy)  # type: ignore since list values are handled above
    except FileNotFoundError as err:
        if raise_error:
            raise err
        else:
            return {}

    with open(fname, "rb") as file:
        configuration = reader(file)

    if sub_config_keys is None:
        return configuration

    return config_walker(configuration, sub_config_keys)

config_walker

config_walker(configuration_dictionary, sub_config_keys)

goes upstream from the currently executed file and finds the file config_fname and returns the sub_config_keys

Parameters:
  • configuration_dictionary (Dict[str, Any]) –

    containing the configuration as dictionary of dictionaries

  • sub_config_keys (list[str]) –

    defines the keys of the branches that are supposed to be returned

Example:

>>> config_walker({"a": {"b" : {"b1" : 1, "b2" : 2}}}, ["a", "b"])
{'b1': 1, 'b2': 2}
Source code in src\simpleconfigfinder\configfinder.py
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
def config_walker(
    configuration_dictionary: Dict[str, Any], sub_config_keys: list[str]
) -> Dict[str, Any]:
    """goes upstream from the currently executed file and finds the file `config_fname` and returns the `sub_config_keys`

    Args:
        configuration_dictionary: containing the configuration as dictionary of dictionaries
        sub_config_keys: defines the keys of the branches that are supposed to be returned

    Example:

        >>> config_walker({"a": {"b" : {"b1" : 1, "b2" : 2}}}, ["a", "b"])
        {'b1': 1, 'b2': 2}"""

    for i, key in enumerate(sub_config_keys):
        if key in configuration_dictionary:
            configuration_dictionary = configuration_dictionary[key]
        else:
            raise ConfigNotFound(f"configuration {sub_config_keys[: i + 1]} not found")

    return configuration_dictionary

configparser_to_dict

configparser_to_dict(configuration)

converts a configparser element (handling ini files) to a dictionary

Example:

>>> from configparser import ConfigParser
>>> cfg = ConfigParser()
>>> cfg.read_dict({ "a": {"a1": "1", "a2": "2"}})
>>> configparser_to_dict(cfg)
{'DEFAULT': {}, 'a': {'a1': '1', 'a2': '2'}}
Parameters:
  • configuration (ConfigParser) –

    configuration object loaded by the configparser

Returns:
  • Dict[str, Any]

    dictionary of entries

Source code in src\simpleconfigfinder\configfinder.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
def configparser_to_dict(configuration: configparser.ConfigParser) -> Dict[str, Any]:
    """converts a configparser element (handling ini files) to a dictionary

    Example:

        >>> from configparser import ConfigParser
        >>> cfg = ConfigParser()
        >>> cfg.read_dict({ "a": {"a1": "1", "a2": "2"}})
        >>> configparser_to_dict(cfg)
        {'DEFAULT': {}, 'a': {'a1': '1', 'a2': '2'}}

    Args:
        configuration: configuration object loaded by the configparser

    Returns:
        dictionary of entries"""
    return {
        key: value
        if type(value) is not configparser.SectionProxy
        else {k: v for k, v in value.items()}
        for key, value in configuration.items()
    }

find_file

find_file(config_fname, strategy='__main__')

finds the configuration file by checking every parent directory.

Strategy main: Starts with the directory of the currently executed file (__main__.__file__) and searches upstream. For Jupyter Notebooks, os.path.abspath("") will be returned instead.

Strategy cwd: starts with os.cwd()

Parameters:
  • config_fname (str | PurePath) –

    the name of the configuration file

  • strategy (str, default: '__main__' ) –

    can bei either main or cwd

Source code in src\simpleconfigfinder\configfinder.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
def find_file(config_fname: str | PurePath, strategy: str = "__main__") -> PurePath:
    """finds the configuration file by checking every parent directory.

    Strategy __main__:
    Starts with the directory of the currently executed file (`__main__.__file__`) and searches upstream.
    For Jupyter Notebooks, `os.path.abspath("")` will be returned instead.

    Strategy cwd:
    starts with os.cwd()


    Args:
        config_fname: the name of the configuration file
        strategy: can bei either __main__ or cwd"""

    if strategy == "__main__":
        try:
            directory = Path(__main__.__file__).parent
        except AttributeError:
            # above version does not work for *.ipynb
            directory = Path(os.path.abspath(""))
    elif strategy == "cwd":
        directory = Path(os.getcwd())
    else:
        raise ValueError(f"unknown strategy {strategy}")

    while directory.parent != directory:
        if (directory / config_fname).exists():
            return directory / config_fname

        # go one up
        directory = directory.parent

    raise FileNotFoundError(f"'{config_fname}' was not found")

multi_config_finder

multi_config_finder(
    config_fname,
    sub_config_keys=None,
    raise_error=True,
    additional_readers=None,
    strategy="__main__",
)

goes upstream from the currently executed file and finds the file config_fname and returns the sub_config_keys

Starts with the directory of the currently executed file (__main__.__file__) and searches upstream.

In case there are multiple configuration files provided and keys are in multiple of them, the first occurence will be returned. This function first combines all files and afterwards applies the sub_config_keys

Parameters:
  • config_fname (list[str] | list[PurePath]) –

    List of configuration files. The output will be combined. In case of double definition, input from earlier mentioned files will not be over-written (but additional keys added).

  • sub_config_keys (Optional[list[str]], default: None ) –

    A list of the keys to identify the sub-configuration. returns the full config if nothing is provided.

  • raise_error

    if errors will be raised in case any of the files are not found

  • additional_readers (Optional[Dict[str, Callable[[Any], Dict[str, Any]]]], default: None ) –

    dictionary to define for file extensions which readers will be used (e.g. for yaml via {"yaml": yaml.safe_load}). In general this works for any function that can take a file name as string or PurePath and return a dictionary. For a code example see Other Readers

  • strategy (str, default: '__main__' ) –

    will be passed to find_file

Source code in src\simpleconfigfinder\configfinder.py
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
def multi_config_finder(
    config_fname: list[str] | list[PurePath],
    sub_config_keys: Optional[list[str]] = None,
    raise_error=True,
    additional_readers: Optional[Dict[str, Callable[[Any], Dict[str, Any]]]] = None,
    strategy: str = "__main__",
) -> Dict[str, Any]:
    """goes upstream from the currently executed file and finds the file `config_fname` and returns the `sub_config_keys`

    Starts with the directory of the currently executed file (`__main__.__file__`) and searches upstream.

    In case there are multiple configuration files provided and keys are in multiple of them, the first occurence will be returned.
    This function first combines all files and afterwards applies the sub_config_keys

    Args:
        config_fname: List of configuration files. The output will be combined. In case of double definition, input from earlier mentioned files will not be over-written (but additional keys added).
        sub_config_keys: A list of the keys to identify the sub-configuration. returns the full config if nothing is provided.
        raise_error: if errors will be raised in case any of the files are not found
        additional_readers: dictionary to define for file extensions which readers will be used (e.g. for yaml via  {"yaml": yaml.safe_load}). In general this works for any function that can take a file name as string or PurePath and return a dictionary. For a code example see [Other Readers](index.md#other-readers)
        strategy: will be passed to `find_file`
    """

    configs_all = [
        config_finder(
            config_fname=file,
            raise_error=raise_error,
            additional_readers=additional_readers,
            strategy=strategy,
        )
        for file in config_fname
    ]
    configuration = configs_all.pop()

    for cfg in configs_all:
        configuration = combine_dictionaries(configuration, cfg)

    if sub_config_keys is None:
        return configuration

    return config_walker(configuration, sub_config_keys)