CLI Config - Build your python configurations with flexibility and simplicity.
CLI Config is a lightweight library that provides routines to merge nested configs and set parameters from command line. It contains many routines to create and manipulate the config as flatten or nested python dictionaries. It also provides processing functions that can change the whole configuration before and after each config manipulation.
The package was initially designed for machine learning experiments where the number of parameters is huge and a lot of them have to be set by the user between each experiment. If your project matches this description, this package is for you!
Installation
In a new virtual environment, simply install the package via pypi:
pip install cliconfig
This package is OS independent and supported on Linux, macOS and Windows.
Quick start
Create yaml file(s) that contain your default configuration. All the parameters should be listed (see later to organize them simply in case of big config files).
# default1.yaml
param1: 1
param2: 1
letters:
letter1: a
letter2: b
# default2.yaml
param1: 1
param2: 2 # will override param2 from default1.yaml
letters.letter3: c # add a new parameter
Get your config in your python code:
# main.py
from cliconfig import make_config
config = make_config('default1.yaml', 'default2.yaml')
Add additional config file(s) that represent your experiments. They will override the default values.
# first.yaml
letters:
letter3: C # equivalent to "letters.letter3: 'C'"
# second.yaml
param1: -1
letters.letter1: A
Please note that new parameters that are not present in the default configs are not allowed. This restriction is in place to prevent potential typos in the config files from going unnoticed. It also enhances the readability of the default config files and ensures retro-compatibility (see later to circumnavigate it for particular cases). This restriction apart, the package allows a complete liberty of config manipulation.
Run your code with the additional config files AND eventually some other parameters from command line. Please respect the exact syntax for spaces and equal signs.
python main.py --config first.yaml,second.yaml --param2=-2 --letters.letter2='B'
If you have multiple config files it is possible to pass a list with brackets.
Be careful, using --config=first.yaml
will NOT be recognized as an additional config
file (space is important) but as a parameter called "config" with value "first.yaml"
(it then raises an error if no "config" parameter is on the default config).
Now the config look like this:
Config:
param1: -1 # overridden by second.yaml
param2: -2 # overridden by command line args
letters:
letter1: A # overridden by second.yaml
letter2: B # overridden by command line args
letter3: C # overridden by first.yaml
You can also manipulate your config with the following functions:
from cliconfig import load_config, save_config, show_config, update_config
show_config(config) # print config
config.dict # config as native dict
config.dict['letters']['letter1'] # access parameter via dict
config.letters.letter1 # access parameter via dots
config.letters.letter1 = 'G' # modify parameter
del config.letters.letter1 # delete parameter
# Update config with a dict or another config
config = update_config(config, {'letters': {'letter1': 'H'}})
# Save the config as a yaml file
save_config(config, 'myconfig.yaml')
# Load the config and merge with the default configs if provided
# (useful if default configs were updated)
config = load_config('myconfig.yaml', default_config_paths=['default1.yaml', 'default2.yaml'])
The config object is just a wrapper around the config dict that allows to access the parameters via dots (and containing the list of processings, see the Processing section for details). That's all! Therefore, the config object is very light and simple to use.
While the config object is simple, the possibilities to manipulate the config via processings are endless. See the next section for some default features. One of the core idea of this package is to make it easy to add your own config features for your specific needs.
Use tags
By default, the package provides some "tags" represented as strings that start with '@' and are placed at the end of a key containing a parameter. These tags change the way the configuration is processed.
The default tags include:
@merge_add
,@merge_before
, and@merge_after
: These tags merge the dictionary loaded from the specified value (which should be a YAML path) into the current configuration.@merge_add
allows only the merging of new keys and is useful for splitting non-overlapping sub-configurations into multiple files.@merge_before
merges the current dictionary onto the loaded one, while@merge_after
merges the loaded dictionary onto the current one. These tags are used to organize multiple config files.@copy
: This tag copies a parameter value from another parameter name. The value associated to the parameter with this tag should be a string that represents the flattened key. The copied value is then protected from further updates but will be dynamically updated if the copied key change during a merge.@def
: This tag evaluate an expression to define the parameter value. The value associated to a parameter tagged with@def
can contain any parameter name of the configuration. The most useful operators and built-in functions are supported, therandom
andmath
packages are also supported as well as some (safe)numpy
,jax
,tensorflow
,torch
functions. If/else statements and comprehension lists are also supported.@type:<my type>
: This tag checks if the key matches the specified type<my type>
after each update, even if the tag is no longer present. It tries to convert the type if it is not the good one. It supports basic types as well as unions (using either "Union" or "|"), optional values, nested list/set/tuple/dict. For instance:my_param@type:List[Dict[str, int|float]]: [{"a": 0}]
.@select
: This tag select param/sub-config(s) to keep and delete the other param/sub-configs in the same parent config. The tagged key is not deleted if it is in the parent config.@delete
: This tag deletes the param/sub-config from the config before merging. It is usefull to trigger a processing without keeping the key in the config.@new
: This tag allows adding new key(s) to the config that are not already present in the default config(s). It can be used for single parameter or a sub-config. Disclaimer: it is preferable to have exhaustive default config(s) instead of abusing this tag for readability and for security concerning typos.@dict
: This tag allows to have a dictionary object instead of a sub-config where you can modify the keys (see the Edge cases section)
The tags are applied in a particular order that ensure no conflict between them.
Please note that the tags serve as triggers for internal processing and will be automatically removed from the key before you can use it. The tags are designed to give instructions to python without being visible in the config.
It is also possible to combine multiple tags. For example:
# main.yaml
path_1@merge_add: sub1.yaml
path_2@merge_add: sub2.yaml
config3.selection@delete@select: config3.param1
# sub1.yaml
config1:
param@copy@type:int: config2.param
param2@type:float: 1 # int: wrong type -> converted to float
# sub2.yaml
config2.param: 2
config3:
param1@def: "[(config1.param2 + config2.param) / 2] * 2 if config2.param else None"
param2: 3
my_dict@dict:
key1: 1
key2: 2
Note that can also use YAML tags separated with "@" (like key: !tag@tag2 value
)
to add tags instead of putting them in the parameter name (like key@tag@tag2: value
).
Here main.yaml
is interpreted like:
path_1: sub1.yaml
path_2: sub2.yaml
config1:
param: 2 # the value of config2.param
param2: 1.0 # converted to float
config2:
param: 2
config3:
param1: [1.5, 1.5]
# param2 is deleted because it is not in the selection
my_dict: {key1: 1, key2: 2} # (changing the whole dict further is allowed)
Then, all the parameters in config1
have enforced types, changing
config2.param
will also update config1.param
accordingly (which is
protected by direct update). Finally, changing config1.param2
or config2.param
will update config3.param1
accordingly until a new value is set for config3.param1
.
These side effects are not visible in the config but stored on processing objects. They are objects that find the tags, remove them from config and apply a modification. These processing are powerful tools that can be used to highly customize the configuration at each step of the process.
You can easily create your own processing (associated to a tag or not). The way to do it and a further explanation of them is available in the Processing section of the documentation.
Edge cases
YAML does not recognize "None" as a None object, but interprets it as a string. If you wish to set a None object, you can use "null" or "Null" instead.
Please note that YAML does not natively support tuples and sets, and therefore they cannot be used directly in YAML files. However, you can use either cliconfig type conversion (with
@type:<tuple/set>
followed by a list) or cliconfig definition (with@def
followed by a string) to define a set or a tuple. Example:
# config.yaml
my_tuple@type:tuple: [1, 2, 3]
my_tuple2@def: "(1, 2, 3)"
my_set@type:set: [1, 2, 3]
my_set2@def: "{1, 2, 3}"
Note that with @def
you can also create lists, sets and dicts by comprehension.
- In the context of this package, dictionaries are treated as sub-configurations,
which means that modifying or adding keys directly in additional configs may
not be possible (because only default configurations allow adding new keys).
If you need to have a dictionary object where you want to modify the keys, consider
using the
@dict
tag:
For instance:
# default.yaml
logging:
metrics: [train loss, val loss]
styles@dict: {train_loss: red, val_loss: blue}
# additional.yaml
logging:
metrics: [train loss, val acc]
styles@dict: {train_loss: red, val_acc: cyan}
This will not raises an error with the tag @dict
.
The dictionary can be accessed with the dot notation like this:
config.logging.styles.val_acc
like a sub-config (and will return "cyan" here).
"@" is a special character used by the package to identify tags. You can't use it in your parameters names if there are not intended to be tags (but you can use it in your values). It will raise an error if you try to do so.
"dict" and "process_list" are reserved names of config attributes and should not be used as sub-configs or parameters names. If you try to do so, you will not able to access them via dots (
config.<something>
).
Processing
Processings are powerful tools to modify the config at each step of the lifecycle of a configuration. More precisely, you can use processings to modify the full configuration before and after each merge, after loading, and before saving the config.
The processings are applied via a processing object that have five methods
(called "processing" to simplify): premerge
, postmerge
, endbuild
, postload
and presave
. These names correspond to the timing they are applied. Each processing
has the signature:
def premerge(self, flat_config: Config) -> Config:
...
return flat_config
Where Config
is a simple class containing only two attributes (and no methods):
dict
that is the configuration dict and process_list
, the list of processing objects
(we discuss this in a section below). Note that it is
also the class of the object returned by the make_config
function.
They only take a flat config as input i.e a config containing a dict of depth 1 with dot-separated keys and return the modified flat dict (and keep it flat!).
In this section, you will learn how they work and how to create your own to make whatever you want with the config.
Why a flat dict?
The idea is that when we construct a config, we manipulate dictionaries that contain both nested sub-dictionaries and flat keys simultaneously. To simplify this process, the dictionaries are systematically flattened before merging. This approach makes things simpler and prevents duplicated keys within the same configuration, as shown in the example:
config = {'a': {'b': 1}, 'a.b': 2}
More generally, all config modifications are performed using flat dictionaries during config construction, and the same applies to processings. For processings, it is even more interesting as you can have access to the full sub-config names to make your processing if needed.
However, it's important to note that after building your config with make_config
,
the dict will be unflattened to its normal nested configuration structure.
Processing order
The order in which the processings are triggered is crucial because they modify the config and consequently affect the behavior of subsequent processings. To manage this order, the processing class have five float attributes representing the order of the five processing methods: premerge, postmerge, endbuild, postload, and presave.
Here's a basic example to illustrate the significance of the order:
# config1.yaml
merge@merge_add@delete: config2.yaml
param: 1
# config2.yaml
param2: 2
In this example, we want to build a global config using config1.yaml
. This file contains only
half of the parameters, and the other half is in config2.yaml
. Then, we add a key
with the name of our choice, here "merge", tagged with @merge_add
to merge
config2.yaml
before the global config update. We add the @delete
tag to delete
the key "merge" before merging with the global config because in this case, there is
no key with the name "merge" in the global config, and it would raise an error as
it is not possible to ass new keys.
@merge_add
and @delete
has both only a pre-merge effect. Let's check the orders.
It is -20.0
for merge and 30.0
for delete. So merge trigger first, add param2
and
the "merge" key is deleted after it. If the orders were reversed, the key would have been
deleted before merge processing and so the param2
would not have been updated with the
value of 2 and the resulting configuration would potentially not have been
the expected one at all.
Therefore, it is crucial to carefully manage the order when creating your own processings!
Some useful ranges to choose your order:
- not important: order = 0 (default)
- if it checks/modifies the config before applying any processing: order < -25
- if it adds new parameters: -25 < order < -5
- if it updates a value based on itself: -5 < order < 5
- if it updates a value based on other keys: 5 < order < 15
- if it checks a property on a value: 15 < order < 25
- if it deletes other key(s) but you want to trigger the tags before: 25 < order < 35
- final check/modification after all processings: order > 35
Note: a pre-merge order should not be greater than 1000, the order of the default
processing ProcessCheckTags
that raise an error if tags still exist at the end
of the pre-merge step.
Create basic processing
Processing that modify a single value
One of the most useful kind of processing look for parameters which names match a certain pattern (e.g a prefix or a suffix) or contain a specific tag and modify their values depending on their current ones.
To simplify the creation of such a process, we provide the cliconfig.create_processing_value
function.
This function allows you to quickly create a processing that matches a regular
expression or a specific tag name (in which case the tag is removed after pre-merging).
You specify the function to be applied on the value to modify it, and optionally,
the order of the processing. Additionally, there is a persistent
argument, which is
a boolean value indicating whether encountering the tag (if a tag is used) once in
a parameter name will continue to trigger the processing for this parameter
even after the tag is removed. By default, it is False
. Finally, you can set
the processing type (pre-merge, post-merge, etc.) at your convenience. Default is pre-merge.
Here's an example to illustrate:
proc = create_processing_value(lambda x: str(x), 'premerge', tag_name='convert_str', persistent=True)
config = make_config(default_config, process_list=[proc])
In this example, the config {"subconfig.param@convert_str": 1}
will
be converted to {"subconfig.param": "1"}
. Moreover, the keys subconfig.param
will be permanently converted to strings before every merge.
It's worth noting that you can also use functions that have side effects without necessarily changing the value itself. For example, you can use a function to check if a certain condition is met by the value.
It is also possible to pass the flat config as a second argument to the function. For example:
# config.yaml
param: 1
param2@eval: "config.param + 1"
proc = create_processing_value(
lambda x, config: eval(x, {"config": config}),
tag_name="eval",
persistent=False,
)
# (Note that the `eval` function is not safe and the code above
# should not be used in case of untrusted config)
Here the value of param2
will be evaluated to 2 at pre-merge step.
Pre-merge/post-merge processing that protect a property from being modified
Another useful kind of processing is a processing that ensure to keep a certain
property on the value. For this kind of processing, you can use
cliconfig.create_processing_keep_property
. It takes a function that returns
the property from the value, the regex or the tag name like the previous function,
and the order of the pre-merge and the post-merge.
The pre-merge processing looks for keys that match the tag or the regex, apply the function on the value and store the result (= the "property"). The post-merge and end-build processing will check that the property is the same as the one stored during pre-merge. If not, it will raise an error.
Examples:
A processing that enforce the types of all the parameters to be constant (equal to the type of the first value encountered):
create_processing_keep_property(
type,
regex=".*",
premerge_order=15.0,
postmerge_order=15.0,
endbuild_order=15.0
)
A processing that protect parameters tagged with @protect from being changed:
create_processing_keep_property(
lambda x: x,
tag_name="protect",
premerge_order=15.0,
postmerge_order=15.0,
endbuild_order=15.0
)
Each time you choose the order 15.0
because it is a good value for processing that
made checks on the values. Indeed, processings that change the values such as
ProcessCopy
have an order that is generally $\leq$ 10.0.
It is also possible to pass the flat config as a second argument to the function
similarly to create_processing_value
.
Create your processing classes (Advanced)
To create your own processing classes and unlock more possibilities, you simply need to overload the methods of the Processing class to modify the config at the desired timings. To do so, you often need to manipulate tags.
Manipulate the tags
Tags are useful for triggering a processing, as we have seen. However, we need
to be cautious because tagging a key modifies its name and can lead to conflicts
when using processing. To address this issue, we provide tag routines in
cliconfig.tag_routines
. These routines include:
is_tag_in
: Checks if a tag is in a key. It looks for the exact tag name. Iffull_key
is True, it looks for all the flat key, including sub-configs (default: False)clean_tag
: Removes a specific tag (based on its exact name) from a key. It is helpful to remove the tag after pre-merging.clean_all_tags
: Removes all tags from a key. This is helpful each time you need the true parameter name.clean_dict_tags
: Removes all tags from a dictionary and returns the cleaned dict along with a list of keys that contained tags. This is helpful to get all the parameter names of a full dict with tags.
With these tools, we can write a processing, for example, that searches for all
parameters with a tag @look
ant that prints their sorted values at the end of
the post-merging.
class ProcessPrintSorted(Processing):
"""Print the parameters tagged with "@look", sorted by value on post-merge."""
def __init__(self) -> None:
super().__init__()
self.looked_keys: Set[str] = set()
# Pre-merge just look for the tag so order is not important
self.premerge_order = 0.0
# Post-merge should be after the copy processing if we want the final values
# on post-merge
self.postmerge_order = 15.0
def premerge(self, flat_config: Config) -> Config:
"""Pre-merge processing."""
# Browse a freeze version of the dict (because we will modify it to remove tags)
items = list(flat_config.dict.items())
for flat_key, value in items:
if is_tag_in(flat_key, "look"): # Check if the key contains the tag
# Remove the tag and update the dict
new_key = clean_tag(flat_key, "look")
flat_config.dict[new_key] = value
del flat_config.dict[flat_key]
# Store the key
clean_key = clean_all_tags(key) # remove all tags = true parameter name
self.looked_keys.add(clean_key)
return flat_config
def postmerge(self, flat_config: Config) -> Config:
"""Post-merge processing."""
values = []
for key in self.looked_keys:
# IMPORTANT
# ("if key in flat_config.dict:" is important in case of some keys were
# removed or if multiple dicts with different parameters are seen by
# the processing)
if key in flat_config.dict:
values.append(flat_config.dict[key])
print("The sorted looked values are: ", sorted(values))
# If we don't want to keep the looked keys for further print:
self.looked_keys = set()
return flat_config
# And to use it:
config = make_config("main.yaml", process_list=[ProcessPrintSorted()])
Important note: After all pre-merge processings, the config should no longer contains tags as they should be removed by pre-merge processings. Otherwise, a security processing raises an error. It is then not necessary to take care on tags on post-merge, and pre-save.
Merge, save or load configs in processing
The key concept is that as long as we deal with processings, the elementary operations on the config are not actually to merge, save, and load a config, but rather:
- Applying pre-merge processing, then merging, then applying post-merge processing.
- Applying end-build processing at the end of the config building.
- Applying pres-ave processing and then saving a config.
- Loading a config and then applying post-load processing.
These three operations are in cliconfig.process_routines
and called
merge_processing
, end_build_processing
, save_processing
, and load_processing
,
respectively. They take as input a Config object that contains as we see the list of processing.
Now, the trick is that sometimes we want to apply these operations to the processing themselves, particularly when we want to modify a part of the configuration instead of just a single parameter (such as merging two configurations). This is why it is particularly useful to have access to the full Config object and not only the dict.
For example, consider the tag @merge_add
, which triggers a processing before
merging and merges the config loaded from a specified path (the value) into the
current config. We may want to see what happens if we merge a config that also
contains a @merge_add
tag within it:
# main.yaml
config_path1@merge_add: path1.yaml
# path1.yaml
param1: 1
config_path2@merge_add: path2.yaml
# path2.yaml
param2: 2
Now, let's consider we want to merge the config main.yaml
with another config.
During the pre-merge processing, we encounter the tag @merge_add
. This tag is
removed, and the config found at path1.yaml
will be merged into the main.yaml
config. However before this, it triggers the pre-merging.
Therefore, before the merge path1.yaml
, the processing discovers the key
config_path2@merge_add
and merges the config found at path2.yaml
into path1.yaml
.
Then, path1.yaml
is merged into main.yaml
. Finally, the resulting configuration
can be interpreted as follows:
{'param1': 1, 'param2': 2, 'config_path1': 'path1.yaml', 'config_path2': 'path2.yaml'}
before being merged itself with another config. Note that is not only a processing that allows to organize the configuration on multiple files. In fact, it also allows you for instance to choose a particular configuration among several ones by setting the path as value of the tagged key (as long as this config is on the default configs).
Change processing list in processing (Still more advanced)
Note that the processing functions receive the list of processing objects as an input and update as an attribute of the processing object. This means that it is possible to manually modify this list in processing functions.
Warning: The processing list to apply during pre/post-merge, pre-save and post-load are determined before the first processing is applied. Therefore, you can't add or remove processing and expect it to be effective during the current merge/save/load. However, if you modify their internal variables it will be effective immediately.
Here an example of a processing that remove the type check of a parameter in
ProcessTyping
processing. It is then possible for instance to force another
type (it is not possible otherwise).
from cliconfig.processing.builtin import ProcessTyping
class ProcessBypassTyping(Processing):
"""Bypass type check of ProcessTyping for parameters tagged with "@bypass_typing".
In pre-merge it looks for a parameter with the tag "@bypass_typing",
removes it and change the internal ProcessTyping variables to avoid
checking the type of the parameter with ProcessTyping.
"""
def __init__(self) -> None:
super().__init__()
self.bypassed_forced_types: Dict[str, tuple] = {}
# Before ProcessTyping pre-merge to let it change the type
self.premerge_order = 1.0
def premerge(self, flat_config: Config) -> Config:
"""Pre-merge processing."""
items = list(flat_config.dict.items())
for flat_key, value in items:
if is_tag_in(flat_key, "bypass_typing"):
new_key = clean_tag(flat_key, "bypass_typing")
flat_config.dict[new_key] = value
del flat_config.dict[flat_key]
clean_key = clean_all_tags(flat_key)
for processing in flat_config.process_list:
if (isinstance(processing, ProcessTyping)
and clean_key in processing.forced_types):
forced_type = processing.forced_types.pop(clean_key)
self.bypassed_forced_types[clean_key] = forced_type
return flat_config
# Without bypass:
config1 = Config({"a@type:int": 0}, [ProcessBypassTyping(), ProcessTyping()])
config2 = Config({"a@type:str": "a"}, [])
config = merge_flat_processing(config1, config2)
# > Error: try to change the forced type of "a" from int to str
# With bypass:
config1 = Config({"a@type:int": 0}, [ProcessBypassTyping(), ProcessTyping()])
config2 = Config({"a@bypass_typing@type:str": "a"}, [])
config = merge_flat_processing(config1, config2)
# > No error
Alternative ways to create a config
From a python dict
from cliconfig import Config
my_dict = {'param1': 1, 'param2': 2}
config = Config(my_dict)
You can also add built-in or custom processings:
from cliconfig import Config, create_processing_value
from cliconfig.processing.builtin import ProcessCopy
my_dict = {'param1': 1, 'param2': 2}
my_proc = create_processing_value(lambda x: x+1, "premerge", tag_name='add1')
config = Config(my_dict, [my_proc, ProcessCopy()])
From a yaml file without command line arguments (useful for notebooks)
from cliconfig import make_config
config = make_config('my_yaml_file.yaml', no_cli=True)
You can merge multiple yaml files that will be considered as default configs (new parameter names are allowed).
from cliconfig import make_config
config = make_config('config1.yaml', 'config2.yaml', no_cli=True)
You can also pass a list of processing objects like usual.
From a config (make a copy)
from cliconfig.config_routines import copy_config
config2 = copy_config(config)
From two dicts (or configs) to merge one into the other
from cliconfig import Config, update_config
new_config = update_config(Config(config1), config2) # if config1 is a dict
new_config = update_config(config1, config2) # if config1 is a Config
These two lines work whether the config2 is a dict or a Config. Note that the second config will override the first one.
From a list of arguments
Assuming the arguments are under the format
['--key1=value1', '--key2.key3=value2']
:
from cliconfig import Config, unflatten_config
from cliconfig.cli_parser import parse_cli
my_args = ['--key1=value1', '--key2.key3=value2']
config = Config(parse_cli(my_args)[0]) # flat
config = unflatten_config(config)
From a yaml formatted string
from yaml import safe_load
from cliconfig import Config, unflatten_config
yaml_txt = """
a:
d: [2, 3]
b.c: {d: 4, e: 5}
"""
config = Config(safe_load(yaml_txt)) # mix flat and nested
config = unflatten_config(config)
Hyperparameter search with Weights&Biases
Making hyperparameter search easier and more effective with Weights&Biases sweeps! This example shows you how to combine them with cliconfig supporting nested configuration:
# main.py
from cliconfig.config_routines import update_config
from cliconfig.dict_routines import flatten
import wandb
def main() -> None:
"""Main function."""
# Create a cliconfig based on CLI
config = make_config('default.yaml')
# Initialize wandb to create wandb.config eventually modified by sweep
# Note that the config is flattened because wandb sweep does not support
# nested config (yet)
wandb.init(config=flatten(config.dict))
# Sync the cliconfig with wandb.config
config = update_config(config, wandb.config)
# Now the config is eventually updated with the sweep,
# unflattened and ready to be used
run(config)
if __name__ == '__main__':
main()
Now you can create your sweep configuration use wandb sweep either from CLI or from python following the wandb tutorial.
For instance with a configuration containing train and data sub-configurations:
# sweep.yaml
program: main.py
method: bayes
metric:
name: val_loss
goal: minimize
parameters:
train.learning_rate:
distribution: log_uniform_values
min: 0.0001
max: 0.1
train.optimizer.name:
values: ["adam", "sgd"]
data.batch_size:
values: [32, 64, 128]
$ wandb sweep sweep.yaml
sweep_id: ...
$ wandb agent <sweep_id>
This makes a bayesian search over the learning rate, the optimizer and the batch size to minimize the final validation loss.
How to contribute
For development, install the package dynamically and dev requirements with:
pip install -e .
pip install -r requirements-dev.txt
Everyone can contribute to CLI Config, and we value everyone’s contributions. Please see our contributing guidelines for more information 🤗
1# Copyright © 2023 Valentin Goldité 2# 3# This program is free software: you can redistribute it and/or modify 4# it under the terms of the MIT License. 5# This program is distributed in the hope that it will be useful, 6# but WITHOUT ANY WARRANTY; without even the implied warranty of 7# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 8# This project is free to use for COMMERCIAL USE, MODIFICATION, 9# DISTRIBUTION and PRIVATE USE as long as the original license is 10# include as well as this copy right notice. 11"""# CLI Config - Build your python configurations with flexibility and simplicity. 12 13.. include:: ../DOCUMENTATION.md 14""" 15from cliconfig import ( 16 _logger, 17 base, 18 cli_parser, 19 config_routines, 20 dict_routines, 21 process_routines, 22 processing, 23 tag_routines, 24) 25from cliconfig._logger import create_logger 26from cliconfig._version import __version__, __version_tuple__ 27from cliconfig.base import Config 28from cliconfig.config_routines import ( 29 flatten_config, 30 load_config, 31 make_config, 32 save_config, 33 show_config, 34 unflatten_config, 35 update_config, 36) 37from cliconfig.dict_routines import flatten as flatten_dict 38from cliconfig.dict_routines import unflatten as unflatten_dict 39from cliconfig.process_routines import ( 40 merge_flat_paths_processing, 41 merge_flat_processing, 42) 43from cliconfig.processing.base import Processing 44from cliconfig.processing.builtin import DefaultProcessings 45from cliconfig.processing.create import ( 46 create_processing_keep_property, 47 create_processing_value, 48) 49 50_CLICONFIG_LOGGER = create_logger() 51 52__all__ = [ 53 "__version__", 54 "__version_tuple__", 55 "_CLICONFIG_LOGGER", 56 "_logger", 57 "Config", 58 "DefaultProcessings", 59 "Processing", 60 "base", 61 "cli_parser", 62 "config_routines", 63 "create_processing_keep_property", 64 "create_processing_value", 65 "dict_routines", 66 "flatten_config", 67 "flatten_dict", 68 "make_config", 69 "load_config", 70 "merge_flat_paths_processing", 71 "merge_flat_processing", 72 "process_routines", 73 "processing", 74 "save_config", 75 "show_config", 76 "tag_routines", 77 "unflatten_config", 78 "unflatten_dict", 79 "update_config", 80]
API Documentation
11class Config: 12 """Class for configuration. 13 14 Config object contains the config dict and the processing list 15 and no methods except `__init__`, `__repr__`, `__eq__`, 16 `__getattribute__`, `__setattr__` and `__delattr__`. 17 The Config objects are mutable and not hashable. 18 19 Parameters 20 ---------- 21 config_dict : Dict[str, Any] 22 The config dict. 23 process_list : Optional[List[Processing]], optional 24 The list of Processing objects. If None, an empty list is used. 25 The default is None. 26 27 Examples 28 -------- 29 >>> config = Config({"a": 1, "b": {"c": 2}}) 30 >>> config.dict 31 {"a": 1, "b": {"c": 2}} 32 >>> config.process_list 33 [] 34 >>> config.b 35 Config({"c": 2}, []) 36 >>> config.b.c 37 2 38 """ 39 40 def __init__( 41 self, 42 config_dict: Dict[str, Any], 43 process_list: Optional[List["Processing"]] = None, 44 ) -> None: 45 self.dict = config_dict 46 self.process_list = process_list if process_list else [] 47 48 def __dir__(self) -> List[str]: 49 """List of attributes, sub-configurations and parameters.""" 50 return ["dict", "process_list"] + list(self.dict.keys()) 51 52 def __repr__(self) -> str: 53 """Representation of Config object.""" 54 process_classes = [process.__class__.__name__ for process in self.process_list] 55 return f"Config({self.dict}, {process_classes})" 56 57 def __eq__(self, other: Any) -> bool: 58 """Equality operator. 59 60 Two Config objects are equal if their dicts are equal and their 61 lists of Processing objects are equal (order doesn't matter). 62 """ 63 if ( 64 isinstance(other, Config) 65 and self.dict == other.dict 66 and len(self.process_list) == len(other.process_list) 67 ): 68 equal = True 69 for processing in self.process_list: 70 equal = equal and processing in other.process_list 71 for processing in other.process_list: 72 equal = equal and processing in self.process_list 73 return equal 74 return False 75 76 def __getattribute__(self, __name: str) -> Any: 77 """Get attribute, sub-configuration or parameter. 78 79 The dict should be nested (unflattened). If it is not the case, 80 you can apply `cliconfig.dict_routines.flatten` on `config.dict` 81 to unflatten it. 82 """ 83 if __name in ["dict", "process_list"]: 84 return super().__getattribute__(__name) 85 if __name not in self.dict: 86 keys = ", ".join(self.dict.keys()) 87 raise AttributeError( # pylint: disable=raise-missing-from 88 f"Config has no attribute '{__name}'. Available keys are: {keys}." 89 ) 90 if isinstance(self.dict[__name], dict): 91 # If the attribute is a dict, return a Config object 92 # so that we can access the nested keys with multiple dots 93 return Config(self.dict[__name], process_list=self.process_list) 94 return self.dict[__name] 95 96 def __setattr__(self, __name: str, value: Any) -> None: 97 """Set attribute, sub-configuration or parameter. 98 99 The dict should be nested (unflattened). If it is not the case, 100 you can apply `cliconfig.dict_routines.flatten` on `config.dict` 101 to unflatten it. 102 """ 103 if __name in ["dict", "process_list"]: 104 super().__setattr__(__name, value) 105 else: 106 self.dict[__name] = value 107 108 def __delattr__(self, __name: str) -> None: 109 """Delete attribute, sub-configuration or parameter. 110 111 The dict should be nested (unflattened). If it is not the case, 112 you can apply `cliconfig.dict_routines.flatten` on `config.dict` 113 to unflatten it. 114 """ 115 if __name in ["dict", "process_list"]: 116 super().__delattr__(__name) 117 else: 118 del self.dict[__name]
Class for configuration.
Config object contains the config dict and the processing list
and no methods except __init__
, __repr__
, __eq__
,
__getattribute__
, __setattr__
and __delattr__
.
The Config objects are mutable and not hashable.
Parameters
- config_dict (Dict[str, Any]): The config dict.
- process_list (Optional[List[Processing]], optional): The list of Processing objects. If None, an empty list is used. The default is None.
Examples
>>> config = Config({"a": 1, "b": {"c": 2}})
>>> config.dict
{"a": 1, "b": {"c": 2}}
>>> config.process_list
[]
>>> config.b
Config({"c": 2}, [])
>>> config.b.c
2
945class DefaultProcessings: 946 """Default list of built-in processings. 947 948 To add these processings to a Config instance, use: 949 ```python 950 config.process_list += DefaultProcessings().list 951 ``` 952 953 The current default processing list contains: 954 * ProcessCheckTags: protect against '@' in keys at the end of pre-merge) 955 * ProcessMerge (@merge_all, @merge_before, @merge_after): merge multiple 956 files into one. 957 * ProcessCopy (@copy): persistently copy a value from one key to an other 958 and protect it 959 * ProcessTyping (@type:X): force the type of parameter to any type X. 960 * ProcessSelect (@select): select sub-config(s) to keep and delete the 961 other sub-configs in the same parent config. 962 * ProcessDelete (@delete): delete the parameter tagged with @delete on 963 pre-merge. 964 """ 965 966 def __init__(self) -> None: 967 self.list: List[Processing] = [ 968 ProcessCheckTags(), 969 ProcessMerge(), 970 ProcessCopy(), 971 ProcessDef(), 972 ProcessTyping(), 973 ProcessSelect(), 974 ProcessDelete(), 975 ProcessDict(), 976 ProcessNew(), 977 ]
Default list of built-in processings.
To add these processings to a Config instance, use:
config.process_list += DefaultProcessings().list
The current default processing list contains:
- ProcessCheckTags: protect against '@' in keys at the end of pre-merge)
- ProcessMerge (@merge_all, @merge_before, @merge_after): merge multiple files into one.
- ProcessCopy (@copy): persistently copy a value from one key to an other and protect it
- ProcessTyping (@type:X): force the type of parameter to any type X.
- ProcessSelect (@select): select sub-config(s) to keep and delete the other sub-configs in the same parent config.
- ProcessDelete (@delete): delete the parameter tagged with @delete on pre-merge.
11class Processing: 12 """Processing base class. 13 14 Each processing classes contains pre-merge, post-merge, pre-save 15 and post-load processing. They are used with routines that apply 16 processing in `cliconfig.process_routines` and 17 `cliconfig.config_routines`. 18 19 That are applied in the order defined 20 by the order attribute in case of multiple processing. 21 """ 22 23 def __init__(self) -> None: 24 self.premerge_order = 0.0 25 self.postmerge_order = 0.0 26 self.endbuild_order = 0.0 27 self.presave_order = 0.0 28 self.postload_order = 0.0 29 30 def premerge(self, flat_config: Config) -> Config: 31 """Pre-merge processing. 32 33 Function applied to the flat config to modify it 34 before merging. It takes a flat config and returns a flat config. 35 """ 36 return flat_config 37 38 def postmerge(self, flat_config: Config) -> Config: 39 """Post-merge processing. 40 41 Function applied to the flat config to modify it 42 after merging . It takes a flat config and returns a flat config. 43 """ 44 return flat_config 45 46 def endbuild(self, flat_config: Config) -> Config: 47 """End-build processing. 48 49 Function applied to the flat config to modify it at the end of 50 a building process (typically `cliconfig.config_routines.make_config` 51 or `cliconfig.config_routines.load_config`). 52 It takes a flat config and returns a flat config. 53 """ 54 return flat_config 55 56 def presave(self, flat_config: Config) -> Config: 57 """Pre-save processing. 58 59 Function applied to the flat config to modify it before 60 saving. It takes a flat config and returns a flat config. 61 """ 62 return flat_config 63 64 def postload(self, flat_config: Config) -> Config: 65 """Post-load processing. 66 67 Function applied to the flat config to modify it after 68 loading. It takes a flat config and returns a flat config. 69 """ 70 return flat_config 71 72 def __repr__(self) -> str: 73 """Representation of Processing object.""" 74 return f"{self.__class__.__name__}" 75 76 def __eq__(self, __value: object) -> bool: 77 """Equality operator. 78 79 Two processing are equal if they are the same class and add the same 80 attributes (accessed with `__dict__`). 81 """ 82 equal = ( 83 isinstance(__value, self.__class__) and self.__dict__ == __value.__dict__ 84 ) 85 return equal
Processing base class.
Each processing classes contains pre-merge, post-merge, pre-save
and post-load processing. They are used with routines that apply
processing in cliconfig.process_routines
and
cliconfig.config_routines
.
That are applied in the order defined by the order attribute in case of multiple processing.
30 def premerge(self, flat_config: Config) -> Config: 31 """Pre-merge processing. 32 33 Function applied to the flat config to modify it 34 before merging. It takes a flat config and returns a flat config. 35 """ 36 return flat_config
Pre-merge processing.
Function applied to the flat config to modify it before merging. It takes a flat config and returns a flat config.
38 def postmerge(self, flat_config: Config) -> Config: 39 """Post-merge processing. 40 41 Function applied to the flat config to modify it 42 after merging . It takes a flat config and returns a flat config. 43 """ 44 return flat_config
Post-merge processing.
Function applied to the flat config to modify it after merging . It takes a flat config and returns a flat config.
46 def endbuild(self, flat_config: Config) -> Config: 47 """End-build processing. 48 49 Function applied to the flat config to modify it at the end of 50 a building process (typically `cliconfig.config_routines.make_config` 51 or `cliconfig.config_routines.load_config`). 52 It takes a flat config and returns a flat config. 53 """ 54 return flat_config
End-build processing.
Function applied to the flat config to modify it at the end of
a building process (typically make_config
or load_config
).
It takes a flat config and returns a flat config.
56 def presave(self, flat_config: Config) -> Config: 57 """Pre-save processing. 58 59 Function applied to the flat config to modify it before 60 saving. It takes a flat config and returns a flat config. 61 """ 62 return flat_config
Pre-save processing.
Function applied to the flat config to modify it before saving. It takes a flat config and returns a flat config.
64 def postload(self, flat_config: Config) -> Config: 65 """Post-load processing. 66 67 Function applied to the flat config to modify it after 68 loading. It takes a flat config and returns a flat config. 69 """ 70 return flat_config
Post-load processing.
Function applied to the flat config to modify it after loading. It takes a flat config and returns a flat config.
141def create_processing_keep_property( 142 func: Callable, 143 regex: Optional[str] = None, 144 tag_name: Optional[str] = None, 145 premerge_order: float = 0.0, 146 postmerge_order: float = 0.0, 147 endbuild_order: float = 0.0, 148) -> Processing: 149 """Create a processing object that keep a property from a value using tag or regex. 150 151 The pre-merge processing looks for keys that match the tag or the regex, apply 152 the function func on the value and store the result (= the "property"): 153 `property = func(flat_dict[key])`. 154 The post-merge processing will check that the property is the same as the one 155 stored during pre-merge. If not, it will raise a ValueError. 156 157 It also possible to pass the flat config as a second argument of the function 158 `func`. In this case, the function apply 159 `property = func(flat_dict[key], flat_config)`. 160 161 Parameters 162 ---------- 163 func : Callable 164 The function to apply to the value (and eventually the flat config) 165 to define the property to keep. 166 property = func(flat_dict[key]) or func(flat_dict[key], flat_config) 167 regex : Optional[str] 168 The regex to match the key. 169 tag_name : Optional[str] 170 The tag (without "@") to match the key. The values are modified when 171 triggering the pattern ".*@<tag_name>.*" and the tag is removed from the key. 172 premerge_order : float, optional 173 The pre-merge order, by default 0.0 174 postmerge_order : float, optional 175 The post-merge order, by default 0.0 176 endbuild_order : float, optional 177 The end-build order, by default 0.0 178 179 Raises 180 ------ 181 ValueError 182 If both tag and regex are provided or if none of them are provided. 183 184 Returns 185 ------- 186 Processing 187 The processing object with the pre-merge and post-merge methods. 188 189 Examples 190 -------- 191 A processing that enforce the types of all the parameters to be constant 192 (equal to the type of the first value encountered): 193 194 ```python 195 create_processing_keep_property( 196 type, 197 regex=".*", 198 premerge_order=15.0, 199 postmerge_order=15.0, 200 endbuild_order=15.0 201 ) 202 ``` 203 204 A processing that protect parameters tagged with @protect from being changed: 205 206 ```python 207 create_processing_keep_property( 208 lambda x: x, 209 tag_name="protect", 210 premerge_order=15.0, 211 postmerge_order=15.0 212 ) 213 ``` 214 """ 215 if tag_name is not None: 216 if regex is not None: 217 raise ValueError("You must provide a tag or a regex but not both.") 218 else: 219 if regex is None: 220 raise ValueError( 221 "You must provide a tag or a regex (to trigger the value update)." 222 ) 223 processing = _ProcessingKeepProperty( 224 func, 225 regex=regex, 226 tag_name=tag_name, 227 premerge_order=premerge_order, 228 postmerge_order=postmerge_order, 229 endbuild_order=endbuild_order, 230 ) 231 return processing
Create a processing object that keep a property from a value using tag or regex.
The pre-merge processing looks for keys that match the tag or the regex, apply
the function func on the value and store the result (= the "property"):
property = func(flat_dict[key])
.
The post-merge processing will check that the property is the same as the one
stored during pre-merge. If not, it will raise a ValueError.
It also possible to pass the flat config as a second argument of the function
func
. In this case, the function apply
property = func(flat_dict[key], flat_config)
.
Parameters
- func (Callable): The function to apply to the value (and eventually the flat config) to define the property to keep. property = func(flat_dict[key]) or func(flat_dict[key], flat_config)
- regex (Optional[str]): The regex to match the key.
- tag_name (Optional[str]):
The tag (without "@") to match the key. The values are modified when
triggering the pattern ".@
. " and the tag is removed from the key. - premerge_order (float, optional): The pre-merge order, by default 0.0
- postmerge_order (float, optional): The post-merge order, by default 0.0
- endbuild_order (float, optional): The end-build order, by default 0.0
Raises
- ValueError: If both tag and regex are provided or if none of them are provided.
Returns
- Processing: The processing object with the pre-merge and post-merge methods.
Examples
A processing that enforce the types of all the parameters to be constant (equal to the type of the first value encountered):
create_processing_keep_property(
type,
regex=".*",
premerge_order=15.0,
postmerge_order=15.0,
endbuild_order=15.0
)
A processing that protect parameters tagged with @protect from being changed:
create_processing_keep_property(
lambda x: x,
tag_name="protect",
premerge_order=15.0,
postmerge_order=15.0
)
14def create_processing_value( 15 func: Union[Callable[[Any], Any], Callable[[Any, Config], Any]], 16 processing_type: str = "premerge", 17 *, 18 regex: Optional[str] = None, 19 tag_name: Optional[str] = None, 20 order: float = 0.0, 21 persistent: bool = False, 22) -> Processing: 23 r"""Create a processing object that modifies a value in config using tag or regex. 24 25 The processing is applied on pre-merge. It triggers when the key matches 26 the tag or the regex. The function apply `flat_dict[key] = func(flat_dict[key])`. 27 You must only provide one of tag or regex. If tag is provided, the tag will be 28 removed from the key during pre-merge. 29 30 It also possible to pass the flat config as a second argument of the function 31 `func`. In this case, the function apply 32 `flat_dict[key] = func(flat_dict[key], flat_config)`. 33 34 Parameters 35 ---------- 36 func : Callable 37 The function to apply to the value (and eventually the flat config) 38 to make the new value so that: 39 flat_dict[key] = func(flat_dict[key]) or func(flat_dict[key], flat_config) 40 processing_type : str, optional 41 One of "premerge", "postmerge", "presave", "postload" or "endbuild". 42 Timing to apply the value update. In all cases the tag is removed on pre-merge. 43 By default "premerge". 44 regex : Optional[str] 45 The regex to match the key. 46 tag_name : Optional[str] 47 The tag (without "@") to match the key. The tag is removed on pre-merge. 48 order : int, optional 49 The pre-merge order. By default 0.0. 50 persistent : bool, optional 51 If True, the processing will be applied on all keys that have already 52 matched the tag before. By nature, using regex make the processing 53 always persistent. By default, False. 54 55 Raises 56 ------ 57 ValueError 58 If both tag and regex are provided or if none of them are provided. 59 ValueError 60 If the processing type is not one of "premerge", "postmerge", "presave", 61 "postload" or "endbuild". 62 63 Returns 64 ------- 65 processing : Processing 66 The processing object with the pre-merge method. 67 68 69 Examples 70 -------- 71 With the following config and 2 processings: 72 73 ```yaml 74 # config.yaml 75 neg_number1: 1 76 neg_number2: 1 77 neg_number3@add1: 1 78 ``` 79 80 ```python 81 # main.py 82 proc2 = create_processing_value( 83 lambda val: -val, 84 regex="neg_number.*", 85 order=0.0 86 ) 87 proc1 = create_processing_value( 88 lambda val: val + 1, 89 tag_name="add1", 90 order=1.0 91 ) 92 ``` 93 94 When config.yaml is merged with an other config, it will be considered 95 before merging as: 96 97 ```python 98 {'number1': -1, 'number2': -1, 'number3': 0} 99 ``` 100 101 Using the config as a second argument of the function: 102 103 ```yaml 104 # config.yaml 105 param1: 1 106 param2@eval: "config.param1 + 1" 107 ``` 108 109 ```python 110 # main.py 111 proc = create_processing_value( 112 lambda val, config: eval(val, {'config': config}), 113 processing_type='postmerge', 114 tag_name='eval', 115 persistent=False 116 ) 117 ``` 118 119 After config.yaml is merged with another config, param2 will be evaluated 120 as 2 (except if config.param1 has changed with a processing before). 121 """ 122 if tag_name is not None: 123 if regex is not None: 124 raise ValueError("You must provide a tag or a regex but not both.") 125 else: 126 if regex is None: 127 raise ValueError( 128 "You must provide a tag or a regex (to trigger the value update)." 129 ) 130 proc = _ProcessingValue( 131 func, 132 processing_type, 133 regex=regex, 134 tag_name=tag_name, 135 order=order, 136 persistent=persistent, 137 ) 138 return proc
Create a processing object that modifies a value in config using tag or regex.
The processing is applied on pre-merge. It triggers when the key matches
the tag or the regex. The function apply flat_dict[key] = func(flat_dict[key])
.
You must only provide one of tag or regex. If tag is provided, the tag will be
removed from the key during pre-merge.
It also possible to pass the flat config as a second argument of the function
func
. In this case, the function apply
flat_dict[key] = func(flat_dict[key], flat_config)
.
Parameters
- func (Callable): The function to apply to the value (and eventually the flat config) to make the new value so that: flat_dict[key] = func(flat_dict[key]) or func(flat_dict[key], flat_config)
- processing_type (str, optional): One of "premerge", "postmerge", "presave", "postload" or "endbuild". Timing to apply the value update. In all cases the tag is removed on pre-merge. By default "premerge".
- regex (Optional[str]): The regex to match the key.
- tag_name (Optional[str]): The tag (without "@") to match the key. The tag is removed on pre-merge.
- order (int, optional): The pre-merge order. By default 0.0.
- persistent (bool, optional): If True, the processing will be applied on all keys that have already matched the tag before. By nature, using regex make the processing always persistent. By default, False.
Raises
- ValueError: If both tag and regex are provided or if none of them are provided.
- ValueError: If the processing type is not one of "premerge", "postmerge", "presave", "postload" or "endbuild".
Returns
- processing (Processing): The processing object with the pre-merge method.
Examples
With the following config and 2 processings:
# config.yaml
neg_number1: 1
neg_number2: 1
neg_number3@add1: 1
# main.py
proc2 = create_processing_value(
lambda val: -val,
regex="neg_number.*",
order=0.0
)
proc1 = create_processing_value(
lambda val: val + 1,
tag_name="add1",
order=1.0
)
When config.yaml is merged with an other config, it will be considered before merging as:
{'number1': -1, 'number2': -1, 'number3': 0}
Using the config as a second argument of the function:
# config.yaml
param1: 1
param2@eval: "config.param1 + 1"
# main.py
proc = create_processing_value(
lambda val, config: eval(val, {'config': config}),
processing_type='postmerge',
tag_name='eval',
persistent=False
)
After config.yaml is merged with another config, param2 will be evaluated as 2 (except if config.param1 has changed with a processing before).
250def flatten_config(config: Config) -> Config: 251 """Flatten a config. 252 253 Parameters 254 ---------- 255 config : Config 256 The config to flatten. 257 258 Returns 259 ------- 260 confg : Config 261 The config containing a flattened dict. 262 """ 263 config.dict = flatten(config.dict) 264 return config
Flatten a config.
Parameters
- config (Config): The config to flatten.
Returns
- confg (Config): The config containing a flattened dict.
158def flatten(in_dict: Dict[str, Any]) -> Dict[str, Any]: 159 """Flatten dict then return it (flat keys are built with dots). 160 161 Work even if in_dict is a mix of nested and flat dictionaries. 162 For instance like this: 163 164 >>> flatten({'a.b': {'c': 1}, 'a': {'b.d': 2}, 'a.e': {'f.g': 3}}) 165 {'a.b.c': 1, 'a.b.d': 2, 'a.e.f.g': 3} 166 167 Parameters 168 ---------- 169 in_dict : Dict[str, Any] 170 The dict to flatten. It can be nested, already flat or a mix of both. 171 172 Raises 173 ------ 174 ValueError 175 If dict has some conflicting keys (like `{'a.b': <x>, 'a': {'b': <y>}}`). 176 177 Returns 178 ------- 179 flat_dict : Dict[str, Any] 180 The flattened dict. 181 182 Examples 183 -------- 184 ```python 185 >>> flatten({'a.b': 1, 'a': {'c': 2}, 'd': 3}) 186 {'a.b': 1, 'a.c': 2, 'd': 3} 187 >>> flatten({'a.b': {'c': 1}, 'a': {'b.d': 2}, 'a.e': {'f.g': 3}}) 188 {'a.b.c': 1, 'a.b.d': 2, 'a.e.f.g': 3} 189 >>> flatten({'a.b': 1, 'a': {'b': 1}}) 190 ValueError: duplicated key 'a.b' 191 >>> flatten({'a.b': 1, 'a': {'c': {}}, 'a.c': 3}) 192 {'a.b': 1, 'a.c': 3} 193 ``` 194 .. note:: 195 Nested empty dict are ignored even if they are conflicting (see last example). 196 """ 197 flat_dict = _flatten(in_dict, reducer="dot") 198 return flat_dict
Flatten dict then return it (flat keys are built with dots).
Work even if in_dict is a mix of nested and flat dictionaries. For instance like this:
>>> flatten({'a.b': {'c': 1}, 'a': {'b.d': 2}, 'a.e': {'f.g': 3}})
{'a.b.c': 1, 'a.b.d': 2, 'a.e.f.g': 3}
Parameters
- in_dict (Dict[str, Any]): The dict to flatten. It can be nested, already flat or a mix of both.
Raises
- ValueError: If dict has some conflicting keys (like
{'a.b': <x>, 'a': {'b': <y>}}
).
Returns
- flat_dict (Dict[str, Any]): The flattened dict.
Examples
>>> flatten({'a.b': 1, 'a': {'c': 2}, 'd': 3})
{'a.b': 1, 'a.c': 2, 'd': 3}
>>> flatten({'a.b': {'c': 1}, 'a': {'b.d': 2}, 'a.e': {'f.g': 3}})
{'a.b.c': 1, 'a.b.d': 2, 'a.e.f.g': 3}
>>> flatten({'a.b': 1, 'a': {'b': 1}})
ValueError: duplicated key 'a.b'
>>> flatten({'a.b': 1, 'a': {'c': {}}, 'a.c': 3})
{'a.b': 1, 'a.c': 3}
Nested empty dict are ignored even if they are conflicting (see last example).
24def make_config( 25 *default_config_paths: str, 26 process_list: Optional[List[Processing]] = None, 27 add_default_processing: bool = True, 28 fallback: str = "", 29 no_cli: bool = False, 30) -> Config: 31 r"""Make a config from default config(s) and CLI argument(s) with processing. 32 33 The function uses the CLI Config routines `cliconfig.cli_parser.parse_cli` 34 to parse the CLI arguments and merge them with 35 `cliconfig.process_routines.merge_flat_paths_processing`, applying 36 the pre-merge and post-merge processing functions on each merge. 37 38 Parameters 39 ---------- 40 default_config_paths : Tuple[str] 41 Paths to default configs. They are merged in order and new keys 42 are allowed. 43 process_list: Optional[List[Processing]], optional 44 The list of processing to apply during each merge. None for empty list. 45 By default None. 46 add_default_processing : bool, optional 47 If add_default_processing is True, the default processings 48 (found on `cliconfig.processing.builtin.DefaultProcessings`) are added to 49 the list of processings. By default True. 50 fallback : str, optional 51 Path of the configuration to use if no additional config is provided 52 with `--config`. No fallback config if empty string (default), 53 in that case, the config is the default configs plus the CLI arguments. 54 no_cli : bool, optional 55 If True, the CLI arguments are not parsed and the config is only 56 built from the default_config_paths in input and the 57 fallback argument is ignored. By default False. 58 59 Raises 60 ------ 61 ValueError 62 If additional configs have new keys that are not in default configs. 63 64 Returns 65 ------- 66 config : Config 67 The nested built config. Contains the config dict (config.dict) and 68 the processing list (config.process_list) which can be used to apply 69 further processing routines. 70 71 Examples 72 -------- 73 ```python 74 # main.py 75 config = make_config('data.yaml', 'model.yaml', 'train.yaml') 76 ``` 77 78 ```script 79 python main.py -- config [bestmodel.yaml,mydata.yaml] \ 80 --architecture.layers.hidden_dim=64 81 ``` 82 83 .. note:: 84 Setting additional arguments from CLI that are not in default configs 85 does NOT raise an error but only a warning. This ensures the compatibility 86 with other CLI usage (e.g notebook, argparse, etc.) 87 """ 88 logger = cliconfig._CLICONFIG_LOGGER # pylint: disable=W0212 89 # Create the processing list 90 process_list_: List[Processing] = [] if process_list is None else process_list 91 if add_default_processing: 92 process_list_ += DefaultProcessings().list 93 config = Config({}, process_list_) 94 if no_cli: 95 additional_config_paths: List[str] = [] 96 cli_params_dict: Dict[str, Any] = {} 97 else: 98 additional_config_paths, cli_params_dict = parse_cli(sys.argv) 99 if not additional_config_paths and fallback: 100 # Add fallback config 101 additional_config_paths = [fallback] 102 # Merge default configs and additional configs 103 for i, paths in enumerate([default_config_paths, additional_config_paths]): 104 # Allow new keys for default configs only 105 allow_new_keys = i == 0 106 for path in paths: 107 config = merge_flat_paths_processing( 108 config, 109 path, 110 allow_new_keys=allow_new_keys, 111 preprocess_first=False, # Already processed 112 ) 113 114 # Allow new keys for CLI parameters but do not merge them and raise 115 # warning. 116 cli_params_dict = flatten(cli_params_dict) 117 new_keys, keys = [], list(cli_params_dict.keys()) 118 for key in keys: 119 if ( 120 not is_tag_in(key, "new", full_key=True) 121 and clean_all_tags(key) not in config.dict 122 ): 123 # New key: delete it 124 new_keys.append(clean_all_tags(key)) 125 del cli_params_dict[key] 126 if new_keys: 127 new_keys_message = " - " + "\n - ".join(new_keys) 128 message = ( 129 "[CONFIG] New keys found in CLI parameters " 130 f"that will not be merged:\n{new_keys_message}" 131 ) 132 logger.warning(message) 133 # Merge CLI parameters 134 cli_params_config = Config(cli_params_dict, []) 135 config = merge_flat_processing( 136 config, cli_params_config, allow_new_keys=False, preprocess_first=False 137 ) 138 message = ( 139 f"[CONFIG] Merged {len(default_config_paths)} default config(s), " 140 f"{len(additional_config_paths)} additional config(s) and " 141 f"{len(cli_params_dict)} CLI parameter(s)." 142 ) 143 logger.info(message) 144 config = end_build_processing(config) 145 config.dict = unflatten(config.dict) 146 return config
Make a config from default config(s) and CLI argument(s) with processing.
The function uses the CLI Config routines cliconfig.cli_parser.parse_cli
to parse the CLI arguments and merge them with
merge_flat_paths_processing
, applying
the pre-merge and post-merge processing functions on each merge.
Parameters
- default_config_paths (Tuple[str]): Paths to default configs. They are merged in order and new keys are allowed.
- process_list (Optional[List[Processing]], optional): The list of processing to apply during each merge. None for empty list. By default None.
- add_default_processing (bool, optional):
If add_default_processing is True, the default processings
(found on
DefaultProcessings
) are added to the list of processings. By default True. - fallback (str, optional):
Path of the configuration to use if no additional config is provided
with
--config
. No fallback config if empty string (default), in that case, the config is the default configs plus the CLI arguments. - no_cli (bool, optional): If True, the CLI arguments are not parsed and the config is only built from the default_config_paths in input and the fallback argument is ignored. By default False.
Raises
- ValueError: If additional configs have new keys that are not in default configs.
Returns
- config (Config): The nested built config. Contains the config dict (config.dict) and the processing list (config.process_list) which can be used to apply further processing routines.
Examples
# main.py
config = make_config('data.yaml', 'model.yaml', 'train.yaml')
python main.py -- config [bestmodel.yaml,mydata.yaml] \
--architecture.layers.hidden_dim=64
Setting additional arguments from CLI that are not in default configs does NOT raise an error but only a warning. This ensures the compatibility with other CLI usage (e.g notebook, argparse, etc.)
149def load_config( 150 path: str, 151 default_config_paths: Optional[List[str]] = None, 152 process_list: Optional[List[Processing]] = None, 153 *, 154 add_default_processing: bool = True, 155) -> Config: 156 """Load config from a file and merge into optional default configs. 157 158 First merge the default configs together (if any), then load the config 159 from path, apply the post-load processing, and finally merge the loaded 160 config. 161 162 Parameters 163 ---------- 164 path : str 165 The path to the file to load the configuration. 166 default_config_paths : Optional[List[str]], optional 167 Paths to default configs. They are merged in order, new keys are allowed. 168 Then, the loaded config is merged into the result. None for no default configs. 169 By default None. 170 process_list: Optional[List[Processing]] 171 The list of processing to apply after loading and for the merges. 172 If None, no processing is applied. By default None. 173 add_default_processing : bool, optional 174 If add_default_processing is True, the default processings 175 (found on `cliconfig.processing.builtin.DefaultProcessings`) 176 are added to the list of processings. By default True. 177 178 Returns 179 ------- 180 config: Dict[str, Any] 181 The nested loaded config. Contains the config dict (config.dict) and 182 the processing list (config.process_list) which can be used to apply 183 further processing routines. 184 185 .. note:: 186 If default configs are provided, the function does not allow new keys 187 for the loaded config. This is for helping the user to see how to 188 adapt the config file if the default configs have changed. 189 """ 190 # Crate process_list 191 process_list_: List[Processing] = [] if process_list is None else process_list 192 if add_default_processing: 193 process_list_ += DefaultProcessings().list 194 195 config = Config({}, process_list_) 196 if default_config_paths: 197 for config_path in default_config_paths: 198 config = merge_flat_paths_processing( 199 config, 200 config_path, 201 allow_new_keys=True, 202 preprocess_first=False, # Already processed 203 ) 204 loaded_config = load_processing(path, config.process_list) 205 # Update the config list from loaded_config in config 206 config.process_list = loaded_config.process_list 207 # Merge the loaded config into the config and 208 # disallow new keys for loaded config 209 # if default configs are provided 210 config = merge_flat_processing( 211 config, 212 loaded_config, 213 allow_new_keys=default_config_paths is None, 214 preprocess_first=False, 215 ) 216 config = end_build_processing(config) 217 config.dict = unflatten(config.dict) 218 return config
Load config from a file and merge into optional default configs.
First merge the default configs together (if any), then load the config from path, apply the post-load processing, and finally merge the loaded config.
Parameters
- path (str): The path to the file to load the configuration.
- default_config_paths (Optional[List[str]], optional): Paths to default configs. They are merged in order, new keys are allowed. Then, the loaded config is merged into the result. None for no default configs. By default None.
- process_list (Optional[List[Processing]]): The list of processing to apply after loading and for the merges. If None, no processing is applied. By default None.
- add_default_processing (bool, optional):
If add_default_processing is True, the default processings
(found on
DefaultProcessings
) are added to the list of processings. By default True.
Returns
- config (Dict[str, Any]): The nested loaded config. Contains the config dict (config.dict) and the processing list (config.process_list) which can be used to apply further processing routines.
- If default configs are provided, the function does not allow new keys
- for the loaded config. This is for helping the user to see how to
- adapt the config file if the default configs have changed.
99def merge_flat_paths_processing( 100 config_or_path1: Union[str, Config], 101 config_or_path2: Union[str, Config], 102 *, 103 additional_process: Optional[List[Processing]] = None, 104 allow_new_keys: bool = True, 105 preprocess_first: bool = True, 106 preprocess_second: bool = True, 107 postprocess: bool = True, 108) -> Config: 109 """Flatten, merge and apply processing to two configs or their yaml paths. 110 111 Similar to `merge_flat_processing` but allows to pass configs 112 or their yaml paths. Work even if the configs have a mix of nested and flat dicts. 113 If both arguments are configs, the process lists are merged before applying 114 the processing. The duplicate processings (with same internal variables) 115 are removed. 116 117 Parameters 118 ---------- 119 config_or_path1 : Union[str, Config] 120 The first config or its path. 121 config_or_path2 : Union[str, Config] 122 The second config or its path, to merge into first config. 123 additional_process : Optional[List[Processing]], optional 124 Additional processings to apply to the merged config. It can 125 be useful to merge a config from its path while it has some specific 126 processings. 127 allow_new_keys : bool, optional 128 If True, new keys (that are not in config1) are allowed in config2. 129 Otherwise, it raises an error. By default True. 130 preprocess_first : bool, optional 131 If True, apply pre-merge processing to config1. By default True. 132 preprocess_second : bool, optional 133 If True, apply pre-merge processing to config2. By default True. 134 postprocess : bool, optional 135 If True, apply post-merge processing to the merged config. By default True. 136 137 Raises 138 ------ 139 ValueError 140 If allow_new_keys is False and config2 has new keys that are not in config1. 141 ValueError 142 If there are conflicting keys when flatten one of the dicts. 143 144 Returns 145 ------- 146 flat_config : Config 147 The merged flat config. 148 """ 149 configs = [] 150 for config_or_path in [config_or_path1, config_or_path2]: 151 if isinstance(config_or_path, str): 152 config_dict = load_dict(config_or_path) 153 config = Config(config_dict, []) 154 elif isinstance(config_or_path, Config): 155 config = config_or_path 156 elif isinstance(config_or_path, dict): 157 raise ValueError( 158 "config_or_path must be a Config instance or a path to a yaml file " 159 "but you passed a dict. If you want to use it as a valid input, " 160 "you should use Config(<input dict>, []) instead." 161 ) 162 else: 163 raise ValueError( 164 "config_or_path must be a Config instance or a path to a yaml file." 165 ) 166 configs.append(config) 167 config1, config2 = configs[0], configs[1] 168 if additional_process is not None: 169 config1.process_list.extend(additional_process) 170 config2.process_list.extend(additional_process) 171 flat_config = merge_flat_processing( 172 config1, 173 config2, 174 allow_new_keys=allow_new_keys, 175 preprocess_first=preprocess_first, 176 preprocess_second=preprocess_second, 177 postprocess=postprocess, 178 ) 179 return flat_config
Flatten, merge and apply processing to two configs or their yaml paths.
Similar to merge_flat_processing
but allows to pass configs
or their yaml paths. Work even if the configs have a mix of nested and flat dicts.
If both arguments are configs, the process lists are merged before applying
the processing. The duplicate processings (with same internal variables)
are removed.
Parameters
- config_or_path1 (Union[str, Config]): The first config or its path.
- config_or_path2 (Union[str, Config]): The second config or its path, to merge into first config.
- additional_process (Optional[List[Processing]], optional): Additional processings to apply to the merged config. It can be useful to merge a config from its path while it has some specific processings.
- allow_new_keys (bool, optional): If True, new keys (that are not in config1) are allowed in config2. Otherwise, it raises an error. By default True.
- preprocess_first (bool, optional): If True, apply pre-merge processing to config1. By default True.
- preprocess_second (bool, optional): If True, apply pre-merge processing to config2. By default True.
- postprocess (bool, optional): If True, apply post-merge processing to the merged config. By default True.
Raises
- ValueError: If allow_new_keys is False and config2 has new keys that are not in config1.
- ValueError: If there are conflicting keys when flatten one of the dicts.
Returns
- flat_config (Config): The merged flat config.
21def merge_flat_processing( 22 config1: Config, 23 config2: Config, 24 *, 25 allow_new_keys: bool = True, 26 preprocess_first: bool = True, 27 preprocess_second: bool = True, 28 postprocess: bool = True, 29) -> Config: 30 """Flatten and merge config2 into config1 and apply pre and post processing. 31 32 Work even if the config dicts have a mix of nested and flat dictionaries. 33 If both arguments are configs, the process lists are merged before applying 34 the processing. The duplicate processings (with same internal variables) 35 are removed. 36 37 Parameters 38 ---------- 39 config1 : Config 40 The first config. 41 config2 : Config 42 The second dict to merge into config1. 43 allow_new_keys : bool, optional 44 If True, new keys (that are not in config1) are allowed in config2. 45 Otherwise, it raises an error. By default True. 46 preprocess_first : bool, optional 47 If True, apply pre-merge processing to config1. By default True. 48 preprocess_second : bool, optional 49 If True, apply pre-merge processing to config2. By default True. 50 postprocess : bool, optional 51 If True, apply post-merge processing to the merged config. By default True. 52 53 Raises 54 ------ 55 ValueError 56 If allow_new_keys is False and config2 has new keys that are not in config1. 57 ValueError 58 If there are conflicting keys when flatten one of the dicts. 59 60 Returns 61 ------- 62 flat_config : Config 63 The merged flat config. 64 """ 65 # Flatten the dictionaries 66 config1.dict, config2.dict = _flat_before_merge(config1.dict, config2.dict) 67 # Get the process list of the merge 68 process_list = config1.process_list 69 for process in config2.process_list: 70 # NOTE 2 processings are equal if they are the same class and add the same 71 # attributes. 72 if process not in process_list: 73 process_list.append(process) 74 # Apply the pre-merge processing 75 if preprocess_first: 76 config1.process_list = process_list 77 pre_order_list = sorted(process_list, key=lambda x: x.premerge_order) 78 for processing in pre_order_list: 79 config1 = processing.premerge(config1) 80 process_list = config1.process_list 81 if preprocess_second: 82 config2.process_list = process_list 83 pre_order_list = sorted(process_list, key=lambda x: x.premerge_order) 84 for processing in pre_order_list: 85 config2 = processing.premerge(config2) 86 process_list = config2.process_list 87 # Merge the dictionaries 88 flat_dict = merge_flat(config1.dict, config2.dict, allow_new_keys=allow_new_keys) 89 # Create the new config 90 flat_config = Config(flat_dict, process_list) 91 # Apply the postmerge processing 92 if postprocess: 93 post_order_list = sorted(process_list, key=lambda x: x.postmerge_order) 94 for processing in post_order_list: 95 flat_config = processing.postmerge(flat_config) 96 return flat_config
Flatten and merge config2 into config1 and apply pre and post processing.
Work even if the config dicts have a mix of nested and flat dictionaries. If both arguments are configs, the process lists are merged before applying the processing. The duplicate processings (with same internal variables) are removed.
Parameters
- config1 (Config): The first config.
- config2 (Config): The second dict to merge into config1.
- allow_new_keys (bool, optional): If True, new keys (that are not in config1) are allowed in config2. Otherwise, it raises an error. By default True.
- preprocess_first (bool, optional): If True, apply pre-merge processing to config1. By default True.
- preprocess_second (bool, optional): If True, apply pre-merge processing to config2. By default True.
- postprocess (bool, optional): If True, apply post-merge processing to the merged config. By default True.
Raises
- ValueError: If allow_new_keys is False and config2 has new keys that are not in config1.
- ValueError: If there are conflicting keys when flatten one of the dicts.
Returns
- flat_config (Config): The merged flat config.
221def save_config(config: Config, path: str) -> None: 222 """Save a config and apply pre-save processing before saving. 223 224 Alias for `cliconfig.process_routines.save_processing`. 225 226 Parameters 227 ---------- 228 config : Dict[str, Any] 229 The config to save. 230 path : str 231 The path to the yaml file to save the dict. 232 """ 233 save_processing(config, path)
Save a config and apply pre-save processing before saving.
Alias for cliconfig.process_routines.save_processing
.
Parameters
- config (Dict[str, Any]): The config to save.
- path (str): The path to the yaml file to save the dict.
236def show_config(config: Config) -> None: 237 """Show the config dict in a pretty way. 238 239 The config dict is automatically unflattened before printing. 240 241 Parameters 242 ---------- 243 config : Config 244 The config to show. 245 """ 246 print("Config:") 247 show_dict(config.dict, start_indent=1)
Show the config dict in a pretty way.
The config dict is automatically unflattened before printing.
Parameters
- config (Config): The config to show.
267def unflatten_config(config: Config) -> Config: 268 """Unflatten a config. 269 270 Parameters 271 ---------- 272 config : Config 273 The config to unflatten. 274 275 Returns 276 ------- 277 config : Config 278 The config containing an unflattened dict. 279 """ 280 config.dict = unflatten(config.dict) 281 return config
Unflatten a config.
Parameters
- config (Config): The config to unflatten.
Returns
- config (Config): The config containing an unflattened dict.
201def unflatten(flat_dict: Dict[str, Any]) -> Dict[str, Any]: 202 """Unflatten a flat dict then return it. 203 204 Parameters 205 ---------- 206 flat_dict : Dict[str, Any] 207 The dict to unflatten. Must be a fully flat dict (depth of 1 with keys 208 separated by dots). 209 210 Raises 211 ------ 212 ValueError 213 If flat_dict is not flat and then found conflicts. 214 215 Returns 216 ------- 217 unflat_dict : Dict[str, Any] 218 The output nested dict. 219 220 Examples 221 -------- 222 >>> unflatten({'a.b': 1, 'a.c': 2, 'c': 3}) 223 {'a': {'b': 1, 'c': 2}, 'c': 3} 224 >>> unflatten({'a.b': 1, 'a': {'c': 2}}) 225 ValueError: duplicated key 'a' 226 The dict must be flatten before calling unflatten function. 227 """ 228 try: 229 unflat_dict = _unflatten(flat_dict, splitter="dot") 230 except ValueError as exc: 231 raise ValueError( 232 "The dict must be flatten before calling unflatten function." 233 ) from exc 234 return unflat_dict
Unflatten a flat dict then return it.
Parameters
- flat_dict (Dict[str, Any]): The dict to unflatten. Must be a fully flat dict (depth of 1 with keys separated by dots).
Raises
- ValueError: If flat_dict is not flat and then found conflicts.
Returns
- unflat_dict (Dict[str, Any]): The output nested dict.
Examples
>>> unflatten({'a.b': 1, 'a.c': 2, 'c': 3})
{'a': {'b': 1, 'c': 2}, 'c': 3}
>>> unflatten({'a.b': 1, 'a': {'c': 2}})
ValueError: duplicated key 'a'
The dict must be flatten before calling unflatten function.
284def update_config( 285 config: Config, 286 other: Union[Dict[str, Any], Config], 287 *, 288 allow_new_keys: bool = False, 289) -> Config: 290 """Update a config with a new dict or config with processing triggering. 291 292 The pre-merge, post-merge and end-build processings will be triggered. 293 The resulting config is unflattened. 294 295 Parameters 296 ---------- 297 config : Config 298 The config to update. 299 other : Config | dict 300 The config or dict to update the config with. 301 allow_new_keys : bool, optional 302 If True, allow new keys in the other config. By default False. 303 304 Returns 305 ------- 306 config : Config 307 The updated config. 308 """ 309 other_config = Config(other, []) if isinstance(other, dict) else other 310 config = merge_flat_processing( 311 config, 312 other_config, 313 allow_new_keys=allow_new_keys, 314 preprocess_first=False, 315 ) 316 config = end_build_processing(config) 317 config = unflatten_config(config) 318 return config
Update a config with a new dict or config with processing triggering.
The pre-merge, post-merge and end-build processings will be triggered. The resulting config is unflattened.
Parameters
- config (Config): The config to update.
- other (Config | dict): The config or dict to update the config with.
- allow_new_keys (bool, optional): If True, allow new keys in the other config. By default False.
Returns
- config (Config): The updated config.