edq.core.config

  1import argparse
  2import os
  3import typing
  4
  5import platformdirs
  6
  7import edq.util.dirent
  8import edq.util.json
  9
 10CONFIG_SOURCE_GLOBAL: str = "<global config file>"
 11CONFIG_SOURCE_LOCAL: str = "<local config file>"
 12CONFIG_SOURCE_CLI_FILE: str = "<cli config file>"
 13CONFIG_SOURCE_CLI: str = "<cli argument>"
 14
 15CONFIG_PATHS_KEY: str = 'config_paths'
 16CONFIGS_KEY: str = 'configs'
 17GLOBAL_CONFIG_KEY: str = 'global_config_path'
 18IGNORE_CONFIGS_KEY: str = 'ignore_configs'
 19DEFAULT_CONFIG_FILENAME: str = "edq-config.json"
 20
 21class ConfigSource:
 22    """ A class for storing config source information. """
 23
 24    def __init__(self, label: str, path: typing.Union[str, None] = None) -> None:
 25        self.label = label
 26        """ The label identifying the config (see CONFIG_SOURCE_* constants). """
 27
 28        self.path = path
 29        """ The path of where the config was sourced from. """
 30
 31    def __eq__(self, other: object) -> bool:
 32        if (not isinstance(other, ConfigSource)):
 33            return False
 34
 35        return ((self.label == other.label) and (self.path == other.path))
 36
 37    def __str__(self) -> str:
 38        return f"({self.label}, {self.path})"
 39
 40def get_global_config_path(config_filename: str) -> str:
 41    """ Get the path for the global config file. """
 42
 43    return platformdirs.user_config_dir(config_filename)
 44
 45def get_tiered_config(
 46        config_filename: str = DEFAULT_CONFIG_FILENAME,
 47        legacy_config_filename: typing.Union[str, None] = None,
 48        cli_arguments: typing.Union[dict, argparse.Namespace, None] = None,
 49        local_config_root_cutoff: typing.Union[str, None] = None,
 50    ) -> typing.Tuple[typing.Dict[str, str], typing.Dict[str, ConfigSource]]:
 51    """
 52    Load all configuration options from files and command-line arguments.
 53    Returns a configuration dictionary with the values based on tiering rules and a source dictionary mapping each key to its origin.
 54    """
 55
 56    if (cli_arguments is None):
 57        cli_arguments = {}
 58
 59    config: typing.Dict[str, str] = {}
 60    sources: typing.Dict[str, ConfigSource] = {}
 61
 62    # Ensure CLI arguments are always a dict, even if provided as argparse.Namespace.
 63    if (isinstance(cli_arguments, argparse.Namespace)):
 64        cli_arguments = vars(cli_arguments)
 65
 66    global_config_path = cli_arguments.get(GLOBAL_CONFIG_KEY, get_global_config_path(config_filename))
 67
 68    # Check the global user config file.
 69    if (os.path.isfile(global_config_path)):
 70        _load_config_file(global_config_path, config, sources, CONFIG_SOURCE_GLOBAL)
 71
 72    # Check the local user config file.
 73    local_config_path = _get_local_config_path(
 74        config_filename = config_filename,
 75        legacy_config_filename = legacy_config_filename,
 76        local_config_root_cutoff = local_config_root_cutoff,
 77    )
 78
 79    if (local_config_path is not None):
 80        _load_config_file(local_config_path, config, sources, CONFIG_SOURCE_LOCAL)
 81
 82    # Check the config file specified on the command-line.
 83    config_paths = cli_arguments.get(CONFIG_PATHS_KEY, [])
 84    for path in config_paths:
 85        _load_config_file(path, config, sources, CONFIG_SOURCE_CLI_FILE)
 86
 87    # Check the command-line config options.
 88    cli_configs = cli_arguments.get(CONFIGS_KEY, [])
 89    for cli_config in cli_configs:
 90        if ("=" not in cli_config):
 91            raise ValueError(
 92                f"Invalid configuration option '{cli_config}'."
 93                + " Configuration options must be provided in the format `<key>=<value>` when passed via the CLI."
 94            )
 95
 96        (key, value) = cli_config.split("=", maxsplit = 1)
 97
 98        key = key.strip()
 99        if (key == ""):
100            raise ValueError(f"Found an empty configuration option key associated with the value '{value}'.")
101
102        config[key] = value
103        sources[key] = ConfigSource(label = CONFIG_SOURCE_CLI)
104
105    # Finally, ignore any configs that is specified from CLI command.
106    cli_ignore_configs = cli_arguments.get(IGNORE_CONFIGS_KEY, [])
107    for ignore_config in cli_ignore_configs:
108        config.pop(ignore_config, None)
109        sources.pop(ignore_config, None)
110
111    return config, sources
112
113def _load_config_file(
114        config_path: str,
115        config: typing.Dict[str, str],
116        sources: typing.Dict[str, ConfigSource],
117        source_label: str,
118    ) -> None:
119    """ Loads config variables and the source from the given config JSON file. """
120
121    config_path = os.path.abspath(config_path)
122    for (key, value) in edq.util.json.load_path(config_path).items():
123        key = key.strip()
124        if (key == ""):
125            raise ValueError(f"Found an empty configuration option key associated with the value '{value}'.")
126
127        config[key] = value
128        sources[key] = ConfigSource(label = source_label, path = config_path)
129
130def _get_local_config_path(
131        config_filename: str,
132        legacy_config_filename: typing.Union[str, None] = None,
133        local_config_root_cutoff: typing.Union[str, None] = None,
134    ) -> typing.Union[str, None]:
135    """
136    Search for a config file in hierarchical order.
137    Begins with the provided config file name,
138    optionally checks the legacy config file name if specified,
139    then continues up the directory tree looking for the provided config file name.
140    Returns the path to the first config file found.
141
142    If no config file is found, returns None.
143
144    The cutoff parameter limits the search depth, preventing detection of config file in higher-level directories during testing.
145    """
146
147    # Provided config file is in current directory.
148    if (os.path.isfile(config_filename)):
149        return os.path.abspath(config_filename)
150
151    # Provided legacy config file is in current directory.
152    if (legacy_config_filename is not None):
153        if (os.path.isfile(legacy_config_filename)):
154            return os.path.abspath(legacy_config_filename)
155
156    # Provided config file is found in an ancestor directory up to the root or cutoff limit.
157    parent_dir = os.path.dirname(os.getcwd())
158    return _get_ancestor_config_file_path(
159        parent_dir,
160        config_filename = config_filename,
161        local_config_root_cutoff = local_config_root_cutoff,
162    )
163
164def _get_ancestor_config_file_path(
165        current_directory: str,
166        config_filename: str,
167        local_config_root_cutoff: typing.Union[str, None] = None,
168    ) -> typing.Union[str, None]:
169    """
170    Search through the parent directories (until root or a given cutoff directory(inclusive)) for a config file.
171    Stops at the first occurrence of the specified config file along the path to root.
172    Returns the path if a config file is found.
173    Otherwise, returns None.
174    """
175
176    if (local_config_root_cutoff is not None):
177        local_config_root_cutoff = os.path.abspath(local_config_root_cutoff)
178
179    current_directory = os.path.abspath(current_directory)
180    for _ in range(edq.util.dirent.DEPTH_LIMIT):
181        config_file_path = os.path.join(current_directory, config_filename)
182        if (os.path.isfile(config_file_path)):
183            return config_file_path
184
185        # Check if current directory is root.
186        parent_dir = os.path.dirname(current_directory)
187        if (parent_dir == current_directory):
188            break
189
190        if (local_config_root_cutoff == current_directory):
191            break
192
193        current_directory = parent_dir
194
195    return None
196
197def set_cli_args(parser: argparse.ArgumentParser, extra_state: typing.Dict[str, typing.Any],
198        config_filename: str = DEFAULT_CONFIG_FILENAME,
199        **kwargs: typing.Any,
200    ) -> None:
201    """
202    Set common CLI arguments for configuration.
203    """
204
205    parser.add_argument('--config-global', dest = GLOBAL_CONFIG_KEY,
206        action = 'store', type = str, default = get_global_config_path(config_filename),
207        help = 'Set the default global config file path (default: %(default)s).',
208    )
209
210    parser.add_argument('--config-file', dest = CONFIG_PATHS_KEY,
211        action = 'append', type = str, default = [],
212        help = ('Load config options from a JSON file.'
213            + ' This flag can be specified multiple times.'
214            + ' Files are applied in the order provided and later files override earlier ones.'
215            + ' Will override options form both global and local config files.')
216    )
217
218    parser.add_argument('--config', dest = CONFIGS_KEY,
219        action = 'append', type = str, default = [],
220        help = ('Set a configuration option from the command-line.'
221            + ' Specify options as <key>=<value> pairs.'
222            + ' This flag can be specified multiple times.'
223            + ' The options are applied in the order provided and later options override earlier ones.'
224            + ' Will override options form all config files.')
225    )
226
227    parser.add_argument('--ignore-config-option', dest = IGNORE_CONFIGS_KEY,
228        action = 'append', type = str, default = [],
229        help = ('Ignore any config option with the specified key.'
230            + ' The system-provided default value will be used for that option if one exists.'
231            + ' This flag can be specified multiple times.'
232            + ' Ignored options are processed last.')
233    )
234
235def load_config_into_args(
236        parser: argparse.ArgumentParser,
237        args: argparse.Namespace,
238        extra_state: typing.Dict[str, typing.Any],
239        config_filename: str = DEFAULT_CONFIG_FILENAME,
240        cli_arg_config_map: typing.Union[typing.Dict[str, str], None] = None,
241        **kwargs: typing.Any,
242    ) -> None:
243    """
244    Take in args from a parser that was passed to set_cli_args(),
245    and get the tired configuration with the appropriate parameters, and attache it to args.
246
247    Arguments that appear on the CLI as flags (e.g. `--foo bar`) can be copied over to the config options via `cli_arg_config_map`.
248    The keys of `cli_arg_config_map` represent attributes in the CLI arguments (`args`),
249    while the values represent the desired config name this argument should be set as.
250    For example, a `cli_arg_config_map` of `{'foo': 'baz'}` will make the CLI argument `--foo bar`
251    be equivalent to `--config baz=bar`.
252    """
253
254    if (cli_arg_config_map is None):
255        cli_arg_config_map = {}
256
257    for (cli_key, config_key) in cli_arg_config_map.items():
258        value = getattr(args, cli_key, None)
259        if (value is not None):
260            getattr(args, CONFIGS_KEY).append(f"{config_key}={value}")
261
262    (config_dict, sources_dict) = get_tiered_config(
263        cli_arguments = args,
264        config_filename = config_filename,
265    )
266
267    setattr(args, "_config", config_dict)
268    setattr(args, "_config_sources", sources_dict)
CONFIG_SOURCE_GLOBAL: str = '<global config file>'
CONFIG_SOURCE_LOCAL: str = '<local config file>'
CONFIG_SOURCE_CLI_FILE: str = '<cli config file>'
CONFIG_SOURCE_CLI: str = '<cli argument>'
CONFIG_PATHS_KEY: str = 'config_paths'
CONFIGS_KEY: str = 'configs'
GLOBAL_CONFIG_KEY: str = 'global_config_path'
IGNORE_CONFIGS_KEY: str = 'ignore_configs'
DEFAULT_CONFIG_FILENAME: str = 'edq-config.json'
class ConfigSource:
22class ConfigSource:
23    """ A class for storing config source information. """
24
25    def __init__(self, label: str, path: typing.Union[str, None] = None) -> None:
26        self.label = label
27        """ The label identifying the config (see CONFIG_SOURCE_* constants). """
28
29        self.path = path
30        """ The path of where the config was sourced from. """
31
32    def __eq__(self, other: object) -> bool:
33        if (not isinstance(other, ConfigSource)):
34            return False
35
36        return ((self.label == other.label) and (self.path == other.path))
37
38    def __str__(self) -> str:
39        return f"({self.label}, {self.path})"

A class for storing config source information.

ConfigSource(label: str, path: Optional[str] = None)
25    def __init__(self, label: str, path: typing.Union[str, None] = None) -> None:
26        self.label = label
27        """ The label identifying the config (see CONFIG_SOURCE_* constants). """
28
29        self.path = path
30        """ The path of where the config was sourced from. """
label

The label identifying the config (see CONFIG_SOURCE_* constants).

path

The path of where the config was sourced from.

def get_global_config_path(config_filename: str) -> str:
41def get_global_config_path(config_filename: str) -> str:
42    """ Get the path for the global config file. """
43
44    return platformdirs.user_config_dir(config_filename)

Get the path for the global config file.

def get_tiered_config( config_filename: str = 'edq-config.json', legacy_config_filename: Optional[str] = None, cli_arguments: Union[dict, argparse.Namespace, NoneType] = None, local_config_root_cutoff: Optional[str] = None) -> Tuple[Dict[str, str], Dict[str, ConfigSource]]:
 46def get_tiered_config(
 47        config_filename: str = DEFAULT_CONFIG_FILENAME,
 48        legacy_config_filename: typing.Union[str, None] = None,
 49        cli_arguments: typing.Union[dict, argparse.Namespace, None] = None,
 50        local_config_root_cutoff: typing.Union[str, None] = None,
 51    ) -> typing.Tuple[typing.Dict[str, str], typing.Dict[str, ConfigSource]]:
 52    """
 53    Load all configuration options from files and command-line arguments.
 54    Returns a configuration dictionary with the values based on tiering rules and a source dictionary mapping each key to its origin.
 55    """
 56
 57    if (cli_arguments is None):
 58        cli_arguments = {}
 59
 60    config: typing.Dict[str, str] = {}
 61    sources: typing.Dict[str, ConfigSource] = {}
 62
 63    # Ensure CLI arguments are always a dict, even if provided as argparse.Namespace.
 64    if (isinstance(cli_arguments, argparse.Namespace)):
 65        cli_arguments = vars(cli_arguments)
 66
 67    global_config_path = cli_arguments.get(GLOBAL_CONFIG_KEY, get_global_config_path(config_filename))
 68
 69    # Check the global user config file.
 70    if (os.path.isfile(global_config_path)):
 71        _load_config_file(global_config_path, config, sources, CONFIG_SOURCE_GLOBAL)
 72
 73    # Check the local user config file.
 74    local_config_path = _get_local_config_path(
 75        config_filename = config_filename,
 76        legacy_config_filename = legacy_config_filename,
 77        local_config_root_cutoff = local_config_root_cutoff,
 78    )
 79
 80    if (local_config_path is not None):
 81        _load_config_file(local_config_path, config, sources, CONFIG_SOURCE_LOCAL)
 82
 83    # Check the config file specified on the command-line.
 84    config_paths = cli_arguments.get(CONFIG_PATHS_KEY, [])
 85    for path in config_paths:
 86        _load_config_file(path, config, sources, CONFIG_SOURCE_CLI_FILE)
 87
 88    # Check the command-line config options.
 89    cli_configs = cli_arguments.get(CONFIGS_KEY, [])
 90    for cli_config in cli_configs:
 91        if ("=" not in cli_config):
 92            raise ValueError(
 93                f"Invalid configuration option '{cli_config}'."
 94                + " Configuration options must be provided in the format `<key>=<value>` when passed via the CLI."
 95            )
 96
 97        (key, value) = cli_config.split("=", maxsplit = 1)
 98
 99        key = key.strip()
100        if (key == ""):
101            raise ValueError(f"Found an empty configuration option key associated with the value '{value}'.")
102
103        config[key] = value
104        sources[key] = ConfigSource(label = CONFIG_SOURCE_CLI)
105
106    # Finally, ignore any configs that is specified from CLI command.
107    cli_ignore_configs = cli_arguments.get(IGNORE_CONFIGS_KEY, [])
108    for ignore_config in cli_ignore_configs:
109        config.pop(ignore_config, None)
110        sources.pop(ignore_config, None)
111
112    return config, sources

Load all configuration options from files and command-line arguments. Returns a configuration dictionary with the values based on tiering rules and a source dictionary mapping each key to its origin.

def set_cli_args( parser: argparse.ArgumentParser, extra_state: Dict[str, Any], config_filename: str = 'edq-config.json', **kwargs: Any) -> None:
198def set_cli_args(parser: argparse.ArgumentParser, extra_state: typing.Dict[str, typing.Any],
199        config_filename: str = DEFAULT_CONFIG_FILENAME,
200        **kwargs: typing.Any,
201    ) -> None:
202    """
203    Set common CLI arguments for configuration.
204    """
205
206    parser.add_argument('--config-global', dest = GLOBAL_CONFIG_KEY,
207        action = 'store', type = str, default = get_global_config_path(config_filename),
208        help = 'Set the default global config file path (default: %(default)s).',
209    )
210
211    parser.add_argument('--config-file', dest = CONFIG_PATHS_KEY,
212        action = 'append', type = str, default = [],
213        help = ('Load config options from a JSON file.'
214            + ' This flag can be specified multiple times.'
215            + ' Files are applied in the order provided and later files override earlier ones.'
216            + ' Will override options form both global and local config files.')
217    )
218
219    parser.add_argument('--config', dest = CONFIGS_KEY,
220        action = 'append', type = str, default = [],
221        help = ('Set a configuration option from the command-line.'
222            + ' Specify options as <key>=<value> pairs.'
223            + ' This flag can be specified multiple times.'
224            + ' The options are applied in the order provided and later options override earlier ones.'
225            + ' Will override options form all config files.')
226    )
227
228    parser.add_argument('--ignore-config-option', dest = IGNORE_CONFIGS_KEY,
229        action = 'append', type = str, default = [],
230        help = ('Ignore any config option with the specified key.'
231            + ' The system-provided default value will be used for that option if one exists.'
232            + ' This flag can be specified multiple times.'
233            + ' Ignored options are processed last.')
234    )

Set common CLI arguments for configuration.

def load_config_into_args( parser: argparse.ArgumentParser, args: argparse.Namespace, extra_state: Dict[str, Any], config_filename: str = 'edq-config.json', cli_arg_config_map: Optional[Dict[str, str]] = None, **kwargs: Any) -> None:
236def load_config_into_args(
237        parser: argparse.ArgumentParser,
238        args: argparse.Namespace,
239        extra_state: typing.Dict[str, typing.Any],
240        config_filename: str = DEFAULT_CONFIG_FILENAME,
241        cli_arg_config_map: typing.Union[typing.Dict[str, str], None] = None,
242        **kwargs: typing.Any,
243    ) -> None:
244    """
245    Take in args from a parser that was passed to set_cli_args(),
246    and get the tired configuration with the appropriate parameters, and attache it to args.
247
248    Arguments that appear on the CLI as flags (e.g. `--foo bar`) can be copied over to the config options via `cli_arg_config_map`.
249    The keys of `cli_arg_config_map` represent attributes in the CLI arguments (`args`),
250    while the values represent the desired config name this argument should be set as.
251    For example, a `cli_arg_config_map` of `{'foo': 'baz'}` will make the CLI argument `--foo bar`
252    be equivalent to `--config baz=bar`.
253    """
254
255    if (cli_arg_config_map is None):
256        cli_arg_config_map = {}
257
258    for (cli_key, config_key) in cli_arg_config_map.items():
259        value = getattr(args, cli_key, None)
260        if (value is not None):
261            getattr(args, CONFIGS_KEY).append(f"{config_key}={value}")
262
263    (config_dict, sources_dict) = get_tiered_config(
264        cli_arguments = args,
265        config_filename = config_filename,
266    )
267
268    setattr(args, "_config", config_dict)
269    setattr(args, "_config_sources", sources_dict)

Take in args from a parser that was passed to set_cli_args(), and get the tired configuration with the appropriate parameters, and attache it to args.

Arguments that appear on the CLI as flags (e.g. --foo bar) can be copied over to the config options via cli_arg_config_map. The keys of cli_arg_config_map represent attributes in the CLI arguments (args), while the values represent the desired config name this argument should be set as. For example, a cli_arg_config_map of {'foo': 'baz'} will make the CLI argument --foo bar be equivalent to --config baz=bar.