"""Functions and tools for working with configuration files."""
import logging
from argparse import Namespace
from collections.abc import MutableMapping
from datetime import datetime
from pathlib import Path
from pkgutil import get_data
from pprint import pformat
from typing import TypeVar
import yaml
from topostats import CONFIG_DOCUMENTATION_REFERENCE
from topostats.io import read_yaml
from topostats.logs.logs import LOGGER_NAME
from topostats.utils import convert_path
MutableMappingType = TypeVar("MutableMappingType", bound="MutableMapping")
LOGGER = logging.getLogger(LOGGER_NAME)
[docs]
def reconcile_config_args(args: Namespace | None) -> dict:
"""
Reconcile command line arguments with the default configuration.
Command line arguments take precedence over the default configuration. If a partial configuration file is specified
(with '-c' or '--config-file') the defaults are over-ridden by these values (internally the configuration
dictionary is updated with these values). Any other command line arguments take precedence over both the default
and those supplied in a configuration file (again the dictionary is updated).
The final configuration is validated before processing begins.
Parameters
----------
args : Namespace
Command line arguments passed into TopoStats.
Returns
-------
dict
The configuration dictionary.
"""
update_module(args=args)
default_config = get_data(package=args.module, resource="default_config.yaml")
default_config = yaml.full_load(default_config)
if args is not None:
if args.config_file is not None:
config = read_yaml(str(args.config_file))
# Merge the loaded config with the default config to fill in any defaults that are missing
# Make sure to prioritise the loaded config, so it overrides the default
config = merge_mappings(map1=default_config, map2=config)
else:
# If no config file is provided, use the default config
config = default_config
else:
# If no args are provided, use the default config
config = default_config
# Override the config with command line arguments passed in, eg --output_dir ./output/
if args is not None:
config = update_config(config, args)
return config
[docs]
def update_module(
args: Namespace,
topostats_modules: tuple = (
"bruker-rename",
"create-config",
"curvature",
"disordered_tracing",
"filter",
"grains",
"grainstats",
"nodestats",
"ordered_tracing",
"process",
"splining",
),
) -> None:
"""
Update the `args.module` argument if processing TopoStats objects.
This function allows the sub-parser command to map to the pipeline we wish to use. For now TopoStats has sub-parsers
but it is the intention to introduce sub-sub-parsers for other modules such that eventually we invoke ``topostats```
with a module argument followed by the step of processing.
>>> topostats topostats filter
>>> topostats topostats process
>>> topostats afmslicer slice
>>> topostats afmslicer process
>>> topostats perovstats process
Parameters
----------
args : Namespace
Default arguments that need parsing and updating.
topostats_modules : tuple
List of module names that are unique to TopoStats.
"""
if args.module in topostats_modules:
args.module = "topostats"
[docs]
def merge_mappings(map1: MutableMappingType, map2: MutableMappingType) -> MutableMappingType:
"""
Merge two mappings (dictionaries), with priority given to the second mapping.
Note: Using a Mapping should make this robust to any mapping type, not just dictionaries. MutableMapping was needed
as Mapping is not a mutable type, and this function needs to be able to change the dictionaries.
Parameters
----------
map1 : MutableMapping
First mapping to merge, with secondary priority.
map2 : MutableMapping
Second mapping to merge, with primary priority.
Returns
-------
dict
Merged dictionary.
"""
# Iterate over the second mapping
for key, value in map2.items():
# If the value is another mapping, then recurse
if isinstance(value, MutableMapping):
# If the key is not in the first mapping, add it as an empty dictionary before recursing
map1[key] = merge_mappings(map1.get(key, {}), value)
else:
# Else simply add / override the key value pair
map1[key] = value
return map1
[docs]
def update_config(config: dict, args: dict | Namespace) -> dict:
"""
Update the configuration with any arguments.
Parameters
----------
config : dict
Dictionary of configuration (typically read from YAML file specified with '-c/--config <filename>').
args : Namespace
Command line arguments.
Returns
-------
dict
Dictionary updated with command arguments.
"""
args = vars(args) if isinstance(args, Namespace) else args
config_keys = config.keys()
for arg_key, arg_value in args.items():
if isinstance(arg_value, dict):
update_config(config, arg_value)
else:
if arg_key in config_keys and arg_value is not None:
original_value = config[arg_key]
config[arg_key] = arg_value
LOGGER.debug(f"Updated config config[{arg_key}] : {original_value} > {arg_value} ")
if "base_dir" in config.keys():
config["base_dir"] = convert_path(config["base_dir"])
if "output_dir" in config.keys():
config["output_dir"] = convert_path(config["output_dir"])
return config
[docs]
def update_plotting_config(plotting_config: dict) -> dict:
"""
Update the plotting config for each of the plots in plot_dict.
Ensures that each entry has all the plotting configuration values that are needed.
Parameters
----------
plotting_config : dict
Plotting configuration to be updated.
Returns
-------
dict
Updated plotting configuration.
"""
main_config = plotting_config.copy()
for opt in ["plot_dict", "run"]:
main_config.pop(opt)
LOGGER.debug(
f"Main plotting options that need updating/adding to plotting dict :\n{pformat(main_config, indent=4)}"
)
for image, options in plotting_config["plot_dict"].items():
main_config_temp = main_config.copy()
LOGGER.debug(f"Dictionary for image : {image}")
LOGGER.debug(f"{pformat(options, indent=4)}")
# First update options with values that exist in main_config
# We must however be careful not to update the colourmap for diagnostic traces
if (
not plotting_config["plot_dict"][image]["core_set"]
and "mask_cmap" in plotting_config["plot_dict"][image].keys()
):
main_config_temp.pop("mask_cmap")
plotting_config["plot_dict"][image] = update_config(options, main_config_temp)
LOGGER.debug(f"Updated values :\n{pformat(plotting_config['plot_dict'][image])}")
# Then combine the remaining key/values we need from main_config that don't already exist
for key_main, value_main in main_config_temp.items():
if key_main not in plotting_config["plot_dict"][image]:
plotting_config["plot_dict"][image][key_main] = value_main
LOGGER.debug(f"After adding missing configuration options :\n{pformat(plotting_config['plot_dict'][image])}")
# Make it so that binary images do not have the user-defined z-scale
# applied, but non-binary images do.
if plotting_config["plot_dict"][image]["image_type"] == "binary":
plotting_config["plot_dict"][image]["zrange"] = [None, None]
return plotting_config