rs-matter-stack 0.3.0

Utility for configuring and running rs-matter
#
# Copyright (c) 2021 Project CHIP Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Configuration utilities for MDF tools"""

import argparse
import ast
import logging
import re
from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Pattern, Sequence, Tuple, Union

import humanfriendly  # type: ignore
import memdf.util.nd as nd
import memdf.util.pretty

# A ConfigDescription is a declarative description of configuration options.
#
# In a description dictionary, (most) keys are configuration keys
# and values are dictionaries that MUST contain at least
#   'help':    help string.
#   'default': default value.
# and may contain:
#   'metavar':  if the command line argument takes a value
#   'choices':  if the argument value must be one of several specific values
#   'argparse': additional argument parsing information; most of this is
#               supplied as keyword arguments to `argparse.add_argument()`,
#               except for:
#                   'alias': list of alternate command line option names
#   'postprocess': a callable invoked after argument parsing with three
#               arguments: the config, the key, and the description entry.
#
# Special keys can be used to control argument parser groups. By default any
# configuration key containing a ‘.’ belongs to a group determined by the
# key prefix (the part before the first ‘.’).
#   Config.group_def(group):
#               the value is supplied as keyword arguments to
#               `argparse.add_argument_group()`
#   Config.group_map(prefix):
#               the value contains a key 'group', whose value is the group
#               to be used for configuration keys with the given prefix.
#
ConfigDescription = Mapping[Union[str, Tuple[int, str]], Mapping[str, Any]]


class Config:
    """Key/value store and argument parsing.

    A configuration key is a string where dots (`.`) separate levels in the
    underlying nested dictionary.

    For functions that take a Config, an empty `Config()` is normally
    acceptable. These functions should always assume reasonable defaults,
    so that they can be used without any particular configuration.
    """

    def __init__(self):
        self.d: MutableMapping = {}
        self.argparse = None
        self.argparse_groups = {}
        self.group_alias = {}
        self.postprocess_args = {}
        self.config_desc: ConfigDescription = None
        self.dest_to_key: MutableMapping = {}
        self.key_to_dest: MutableMapping = {}

    # Basic config access

    def get(self, key: str, default: Any = None) -> Any:
        return self.getl(key.split('.'), default)

    def __getitem__(self, key: str) -> Any:
        """[] syntax for configuration.

        Note that this will return `None` for an unknown key, since the
        absence of a configured value is not considered an error.
        """
        return self.get(key)

    def getl(self, keys: nd.Key, default: Any = None) -> Any:
        return nd.get(self.d, keys, default)

    def put(self, key: str, value: Any) -> None:
        self.putl(key.split('.'), value)

    def __setitem__(self, key: str, value: Any) -> None:
        self.put(key, value)

    def putl(self, keys: nd.Key, value: Any) -> None:
        nd.put(self.d, keys, value)

    def update(self, src: Mapping) -> None:
        nd.update(self.d, src)

    # Command line and config file reading

    _GROUP_DEF = 1
    _GROUP_MAP = 2

    @staticmethod
    def group_def(s: str) -> Tuple[int, str]:
        return (Config._GROUP_DEF, s)

    @staticmethod
    def group_map(s: str) -> Tuple[int, str]:
        return (Config._GROUP_MAP, s)

    def init_config(self, desc: ConfigDescription) -> 'Config':
        """Initialize a configuration from a description dictionary.

        Note that this initializes only the key/value store,
        not anything associated with command line argument parsing.
        """
        self.config_desc = desc
        for key, info in desc.items():
            if isinstance(key, str):
                self.put(key, info['default'])

        return self

    def init_args(self, desc: ConfigDescription, *args, **kwargs) -> 'Config':
        """Initialize command line argument parsing."""
        self.argparse = argparse.ArgumentParser(*args, **kwargs)

        # Groups
        for key, info in desc.items():
            if not isinstance(key, tuple):
                continue
            kind, name = key
            if kind == self._GROUP_MAP:
                self.group_alias[name] = info['group']
            elif kind == self._GROUP_DEF:
                self.argparse_groups[name] = self.argparse.add_argument_group(
                    **info)

        # Arguments
        for key, info in desc.items():
            if not isinstance(key, str):
                continue
            if (arg_info := info.get('argparse', {})) is False:
                continue

            arg_info = arg_info.copy()
            name = arg_info.pop('argument', '--' + key.replace('.', '-'))
            names = [name] + arg_info.pop('alias', [])
            info['names'] = names
            for k in ['metavar', 'choices']:
                if k in info:
                    arg_info[k] = info[k]
            default = info['default']
            if not arg_info.get('action'):
                if isinstance(default, list):
                    arg_info['action'] = 'append'
                elif default is False:
                    arg_info['action'] = 'store_true'
                elif default is True:
                    arg_info['action'] = 'store_false'
                elif isinstance(default, int) and 'metavar' not in info:
                    arg_info['action'] = 'count'
            if postprocess := info.get('postprocess'):
                self.postprocess_args[key] = (postprocess, info)

            group: Optional[str] = info.get('group')
            if group is None and (e := key.find('.')) > 0:
                group = key[0:e]
            group = self.group_alias.get(group, group)
            arg_group = self.argparse_groups.get(group, self.argparse)
            arg = arg_group.add_argument(*names,
                                         help=info['help'],
                                         default=self.get(key, default),
                                         **arg_info)
            self.dest_to_key[arg.dest] = key
            self.key_to_dest[key] = arg.dest

        return self

    def init(self, desc: ConfigDescription, *args, **kwargs) -> 'Config':
        """Intialize configuration from a configuration description."""
        self.init_config(desc)
        self.init_args(desc, *args, **kwargs)
        return self

    def parse(self, argv: Sequence[str]) -> 'Config':
        """Parse command line options into a configuration dictionary."""

        # Read config file(s).
        config_parser = argparse.ArgumentParser(add_help=False,
                                                allow_abbrev=False)
        config_arg: Dict[str, Any] = {
            'metavar': 'FILE',
            'default': [],
            'action': 'append',
            'help': 'Read configuration FILE'
        }
        config_parser.add_argument('--config-file', **config_arg)
        self.argparse.add_argument('--config-file', **config_arg)
        config_args, argv = config_parser.parse_known_args(argv[1:])
        for filename in config_args.config_file:
            self.read_config_file(filename)

        # Update argparser defaults.
        defaults = {}
        for dest, key in self.dest_to_key.items():
            default = self.get(key)
            if default is not None:
                defaults[dest] = default
        self.argparse.set_defaults(**defaults)

        # Parse command line arguments and update config.
        args = self.argparse.parse_args(argv)
        for dest, value in vars(args).items():
            if (key := self.dest_to_key.get(dest)) is None:
                key = 'args.' + dest
            self.put(key, value)

        # Configure logging.
        if self.get('log-level') is None:
            verbose = self.get('verbose', 0)
            self.put('log-level',
                     (logging.DEBUG if verbose > 1 else
                      logging.INFO if verbose else logging.WARNING))
        else:
            self.put('log-level',
                     getattr(logging, self.get('log-level').upper()))
        logging.basicConfig(level=self.get('log-level'),
                            format=self.get('log-format'))

        # Postprocess config.
        for key, postprocess in self.postprocess_args.items():
            action, info = postprocess
            action(self, key, info)

        memdf.util.pretty.debug(self.d)
        return self

    def read_config_file(self, filename: str) -> 'Config':
        """Read a configuration file."""
        with open(filename, 'r') as fp:
            d = ast.literal_eval(fp.read())
            nd.update(self.d, d)
        return self

    @staticmethod
    def transpose_dictlist(src: Dict[str, List[str]]) -> Dict[str, str]:
        d: Dict[str, str] = {}
        for k, vlist in src.items():
            for v in vlist:
                d[v] = k
        return d

    def getl_re(self, key: nd.Key) -> Optional[Pattern]:
        """Get a cached compiled regular expression for a config value list."""
        regex_key: nd.Key = ['cache', 're'] + key
        regex: Optional[Pattern] = self.getl(regex_key)
        if not regex:
            branches: Optional[Sequence[str]] = self.getl(key)
            if branches:
                regex = re.compile('|'.join(branches))
            self.putl(regex_key, regex)
        return regex

    def get_re(self, key: str) -> Optional[Pattern]:
        return self.getl_re(key.split('.'))


# Argument parsing helpers


def parse_size(s: str) -> int:
    return humanfriendly.parse_size(s, binary=True) if s else 0


class ParseSizeAction(argparse.Action):
    """argparse helper for humanfriendly sizes"""

    def __call__(self, parser, namespace, values, option_string=None):
        setattr(namespace, self.dest, parse_size(values))


# Config description of options shared by all tools.
CONFIG: ConfigDescription = {
    'log-level': {
        'help':
            'Set logging level: one of critical, error, warning, info, debug.',
        'default': None,
        'choices': ['critical', 'error', 'warning', 'info', 'debug'],
    },
    'log-format': {
        'help': 'Set logging format',
        'metavar': 'FORMAT',
        'default': '%(message)s',
    },
    'verbose': {
        'help': 'Show informational messages; repeat for debugging messages',
        'default': 0,
        'argparse': {
            'alias': ['-v'],
            'action': 'count',
        },
    },
}