Source code for mypythontools.config.config_internal

"""Module with functions for 'config' subpackage."""

from __future__ import annotations
from typing import Any, TypeVar, Union
from copy import deepcopy
import argparse
import sys
from dataclasses import dataclass
from typing import Generic

from typeguard import check_type
from typing_extensions import get_args, get_type_hints, Literal  # pylint: disable=unused-import

from ..property import MyProperty, MyPropertyClass  # pylint: disable=unused-import
from .. import misc
from .. import types

module_globals = globals()

ConfigType = TypeVar("ConfigType", bound="Config")


class ConfigMeta(type):
    """Config metaclass changing config init function.

    Main reason is for being able to define own __init__ but
    still has functionality from parent __init__ that is necessary. With this meta, there is no need
    to use super().__init__ by user.

    As user, you probably will not need it.
    """

    def __init__(cls, name, bases, dct) -> None:
        """Wrap subclass object __init__ to provide Config functionality."""
        type.__init__(cls, name, bases, dct)

        # Avoid base classes here and wrap only user class init
        if name == "Config" and not bases:
            return

        def add_parent__init__(
            self: Config,
            frozen=None,
            *a,
            **kw,
        ):

            self.config_fields = ConfigFields(
                base_config_map={},
                myproperties_list=[],
                vars=[],
                properties_list=[],
                subconfigs=[],
                types=types.get_type_hints(type(self), globalns=module_globals),
            )
            self.do = ConfigDo()
            self.do.setup(config=self)

            # Call user defined init
            cls._original__init__(self, *a, **kw)

            self.do.internal_propagate_config()

            if frozen is None:
                self.config_fields.frozen = True
            else:
                self.config_fields.frozen = frozen

        cls._original__init__ = cls.__init__
        cls.__init__ = add_parent__init__  # type: ignore

    def __getitem__(cls, key):
        """To be able to access attributes also on class for example for documentation."""
        return getattr(cls, key)


class ConfigDo(Generic[ConfigType]):
    def __init__(self) -> None:
        self.config: ConfigType

    def setup(self, config: ConfigType) -> None:
        self.config: ConfigType = config

    def internal_propagate_config(self) -> None:
        """Provide transferring arguments from base or from sub configs.

        Config class has subconfigs. It is possible to access subconfigs attributes from main config or from
        any other level because of this recursive function.
        """
        frozen = self.config.config_fields.frozen
        self.config.config_fields.frozen = False

        for i, j in vars(self.config).items():
            if i.startswith("_") or (not isinstance(j, Config) and callable(j)):
                continue

            # i is iterated subconfig and self.config is higher level config
            if isinstance(j, Config):
                self.config.config_fields.subconfigs.append(j)
                self.config.config_fields.base_config_map.update(j.config_fields.base_config_map)
                j.config_fields.base_config_map = self.config.config_fields.base_config_map

        for i, j in vars(type(self.config)).items():
            if i.startswith("_"):
                continue

            if isinstance(j, property):
                if isinstance(j, MyPropertyClass):
                    self.config.config_fields.myproperties_list.append(i)
                    # Create private variables (e.g. _variable) with content
                    setattr(
                        self.config,
                        j.private_name,
                        j.init_function,
                    )
                else:
                    self.config.config_fields.properties_list.append(i)

            if i not in ["config_fields", "do"] and not callable(i) and not isinstance(j, type):
                self.config.config_fields.vars.append(i)
                self.config.config_fields.base_config_map[i] = self.config

        self.config.config_fields.frozen = frozen

    def copy(self) -> ConfigType:
        """Create deep copy of config and all it's attributes.

        Returns:
            ConfigType: Deep copy.
        """
        frozen = self.config.config_fields.frozen
        copy = deepcopy(self.config)
        self.config.config_fields.frozen = frozen
        return copy

    def update(self, content: dict) -> None:
        """Bulk update with dict values.

        Args:
            content (dict): E.g {"arg_1": "value"}

        Raises:
            AttributeError: If some arg not found in config.
        """
        for i, j in content.items():
            setattr(self.config, i, j)

    def get_dict(self, recursively: bool = True) -> dict:
        """Get flat dictionary with it's values.

        Args:
            recursively (bool, optional): If True, then values from subconfigurations will be also in result.

        Returns:
            dict: Flat config dict.
        """

        dict_of_values = {
            # Values from vars
            **{key: getattr(self.config, key) for key in self.config.config_fields.vars},
            # Values from myproperties
            **{key: getattr(self.config, key) for key in self.config.config_fields.myproperties_list},
            # Values from properties
            **{key: getattr(self.config, key) for key in self.config.config_fields.properties_list},
        }

        if recursively:
            # From sub configs
            for i in self.config.config_fields.subconfigs:
                dict_of_values.update(i.do.get_dict())

        return dict_of_values

    def with_argparse(self, about: str | None = None) -> None:
        """Parse sys.argv flags and update the config.

        For using with CLI. When using `with_argparse` method.

        1) Create parser and add all arguments with help
        2) Parse users' sys args and update config ::

            config.do.with_argparse()

        Now you can use in terminal like. ::

            python my_file.py --config_arg config_value

        Only basic types like int, float, str, list, dict, set are possible as eval for using type like numpy
        array or pandas DataFrame could be security leak.

        Args:
            about (str, optional): Description used in --help. Defaults to None.

        Raises:
            SystemExit: If arg that do not exists in config.

        Note:
            If using boolean, you must specify the value. Just occurrence, e.g. `--my_arg` is not True. so you
            need to use `--my_arg True`.
        """
        if len(sys.argv) <= 1 or misc.GLOBAL_VARS.jupyter:
            return

        # Add settings from command line if used
        parser = argparse.ArgumentParser(usage=about)

        config_dict = self.get_dict()

        for i in config_dict.keys():
            try:
                help_str = type(self.config)[i].__doc__
            except AttributeError:
                help_str = type(self.config.config_fields.base_config_map[i])[i].__doc__

            parser.add_argument(f"--{i}", help=help_str)

        try:
            parsed_args = parser.parse_known_args()
        except SystemExit as err:
            if err.code == 0:
                sys.exit(0)

            raise SystemExit(
                f"Config args parsing failed. Used args {sys.argv}. Check if args and values are correct "
                "format. Each argument must have just one value. Use double quotes if spaces in string. "
                "For dict e.g. \"{'key': 666}\". If using bool, there has to be True or False."
            ) from err

        if parsed_args[1]:
            raise RuntimeError(
                f"Config args parsing failed on unknown args: {parsed_args[1]}."
                "It may happen if variable not exists in config."
            )

        # Non empty command line args
        parser_args_dict = {}

        # All variables are parsed as strings
        # If it should not be string, infer type
        for i, j in parsed_args[0].__dict__.items():

            if j is None:
                continue

            try:
                used_type = type(self.config)[i].allowed_types
            except AttributeError:
                used_type = type(self.config.config_fields.base_config_map[i])[i].allowed_types

            if used_type is not str:
                try:
                    # May fail if for example Litera["string1", "string2"]
                    parser_args_dict[i] = types.str_to_infer_type(j)
                except ValueError:
                    parser_args_dict[i] = j
                except Exception as err:
                    union_types = [type(Union[str, float])]
                    try:
                        from types import UnionType

                        union_types.append(UnionType)
                    except ImportError:
                        pass

                    # UnionType stands for new Union | syntax
                    if type(used_type) in union_types and str in get_args(used_type):
                        parser_args_dict[i] = j
                    else:
                        raise RuntimeError(
                            f"Type not inferred error. Config option {i} type was not inferred and it cannot "
                            "be a string. Only literal_eval is used in type inferring from CLI parsing. "
                            "If you need more complex types like numpy array, try to use it directly from "
                            "python."
                        ) from err
            else:
                parser_args_dict[i] = j

        self.update(parser_args_dict)


@dataclass
class ConfigFields:
    """Attributes of config class. The reason why it is separated is, that usually config values are the most
    important for user. This would make namespace big and intellisense would not worked as expected.
    """

    frozen = False
    """Usually this config is created from someone else that user using this config. Therefore new attributes
    should not be created. It is possible to force it (raise error). It is possible to set frozen to False
    to enable creating new attributes.
    """

    base_config_map: dict
    """You can access attribute from subconfig as well as from main config object, there is proxy mapping
    config dict. If attribute not found on defined object, it will search through this proxy. It's
    populated automatically in metaclass during init.
    """

    types: dict
    """Attribute types used for type validations."""

    vars: list
    """Simple variables."""

    myproperties_list: list
    """List of all custom properties"""

    properties_list: list
    """List of all properties (variables, that can have some getters and setters)."""

    subconfigs: list
    """List of subconfigurations."""


[docs]class Config(metaclass=ConfigMeta): # type: ignore """Main config class. You can find working examples in module docstrings. """ config_fields: ConfigFields def __init_subclass__(cls) -> None: # It would be better to define do on instance level and not class as class variable, but this is the # only way I found to have type hints in do methods. It is necessary to pass instance with do.setup cls.do: ConfigDo[cls] def __new__(cls, *args, **kwargs): """Just control that class is subclassed and not instantiated.""" if cls is Config: raise TypeError("Config is not supposed to be instantiated only to be subclassed.") return object.__new__(cls, *args, **kwargs) def __deepcopy__(self, memo): """Provide copy functionality.""" cls = self.__class__ result = cls.__new__(cls) # result.do.setup(result) memo[id(self)] = result for i, j in self.__dict__.items(): if isinstance(j, staticmethod): atr = j.__func__ else: atr = j object.__setattr__(result, i, deepcopy(atr, memo)) return result def __getattr__(self, name: str): """Control logic if attribute from other subconfig is used.""" try: return getattr(self.config_fields.base_config_map[name], name) except KeyError: if name not in [ "_pytestfixturefunction", "__wrapped__", "pytest_mock_example_attribute_that_shouldnt_exist", "__bases__", "__test__", ]: raise AttributeError(f"Variable '{name}' not found in config.") from None def __setattr__(self, name: str, value: Any) -> None: """Setup new config values. Define logic when setting attributes from other subconfig class.""" if ( name == "config_fields" or not self.config_fields.frozen or name in [ *self.config_fields.vars, *self.config_fields.myproperties_list, *self.config_fields.properties_list, ] ): if name != "config_fields" and name in self.config_fields.types: check_type(expected_type=self.config_fields.types[name], value=value, argname=name) object.__setattr__(self, name, value) elif name in self.config_fields.base_config_map: setattr( self.config_fields.base_config_map[name], name, value, ) else: raise AttributeError( f"Object {str(self)} is frozen. New attributes cannot be set and attribute '{name}' " "not found. Maybe you misspelled name. If you really need to change the value, set " "attribute frozen to false." ) def __getitem__(self, key): """To be able to be able to use same syntax as if using dictionary.""" return getattr(self, key) def __setitem__(self, key, value): """To be able to be able to use same syntax as if using dictionary.""" setattr(self, key, value) def __call__(self, *args: Any, **kwds) -> None: """Just to be sure to not be used in unexpected way.""" raise TypeError("Class is not supposed to be called. Just inherit it to create custom config.")