Howso Engine
import argparse
from argparse import RawTextHelpFormatter
from datetime import date
import sys

from howso.client import __version__ as version_string

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description=None,
                                     formatter_class=RawTextHelpFormatter)
    parser.add_argument(
        '--version', dest='version', action='store_const', const=True,
        required=False, default=False,
        help='Display the version and quit.'
    )

    if len(sys.argv) < 2:
        parser.print_help()
        sys.exit(1)
    else:
        args = parser.parse_args()
        if args.version:
            print(f"""
Howso (tm) client version: {version_string}
Copyright (c) 2018-{date.today().year}, Howso Incorporated. All rights reserved.
""")

howso/__main__.py
howsoai/howso-engine-py
"""
The Python API for the Howso Client.

The Howso Python Client API has two major components,

- client module:
    A basic client that implements the Howso REST API.
- scikit module:
    Implements a scikit-learn Estimator which uses the Howso
    cloud service to make predictions off of fit data.


Additional submodules are included in the package but are for internal client/scikit operations and thus are omitted
from the documentation.

Examples implementations are included in the howso/examples directory.
"""

from .base import AbstractHowsoClient  # noqa: F401
from .client import (  # noqa: F401
    CONFIG_FILE_ENV_VAR,
    DEFAULT_CONFIG_FILE,
    DEFAULT_CONFIG_FILE_ALT,
    get_configuration_path,
    get_howso_client,
    HowsoClient,
)
from .pandas.client import (  # noqa: F401
    HowsoPandasClient,
)

__all__ = [
    "__version__",
    "AbstractHowsoClient",
    "CONFIG_FILE_ENV_VAR",
    "DEFAULT_CONFIG_FILE_ALT",
    "DEFAULT_CONFIG_FILE",
    "get_configuration_path",
    "get_howso_client",
    "HowsoClient",
    "HowsoPandasClient",
]


# The version number is automatically incremented by the pipeline
# It should not be manually changed.
# To change the major/minor number change the number in azure-pipelines.yml

__version__ = "7.1.0"

howso/client/__init__.py
howsoai/howso-engine-py
from abc import ABC, abstractmethod


class AbstractHowsoClient(ABC):
    """The base definition of the Howso client interface."""

    @property
    @abstractmethod
    def active_session(self):
        """Return the active session."""

    @abstractmethod
    def get_version(self):
        """Get Howso version."""

    @abstractmethod
    def create_trainee(
        self, trainee, *,
        library_type=None,
        max_wait_time=None,
        overwrite_trainee=False,
        resources=None,
    ):
        """Create a trainee on the Howso service."""

    @abstractmethod
    def update_trainee(self, trainee):
        """Update an existing trainee in the Howso service."""

    @abstractmethod
    def get_trainee(self, trainee_id):
        """Get an existing trainee from the Howso service."""

    @abstractmethod
    def get_trainee_information(self, trainee_id):
        """Get information about the trainee."""

    @abstractmethod
    def get_trainee_metrics(self, trainee_id):
        """Get metric information for a trainee."""

    @abstractmethod
    def get_trainees(self, search_terms=None):
        """Return a list of all accessible trainees."""

    @abstractmethod
    def delete_trainee(self, trainee_id):
        """Delete a trainee in the Howso service."""

    @abstractmethod
    def copy_trainee(
        self, trainee_id, new_trainee_name=None, *,
        library_type=None,
        resources=None,
    ):
        """Copy a trainee in the Howso service."""

    @abstractmethod
    def acquire_trainee_resources(self, trainee_id, *, max_wait_time=None):
        """Acquire resources for a trainee in the Howso service."""

    @abstractmethod
    def release_trainee_resources(self, trainee_id):
        """Release a trainee's resources from the Howso service."""

    @abstractmethod
    def persist_trainee(self, trainee_id):
        """Persist a trainee in the Howso service."""

    @abstractmethod
    def set_random_seed(self, trainee_id, seed):
        """Set the random seed for the trainee."""

    @abstractmethod
    def train(
        self, trainee_id, cases, features=None, *,
        ablatement_params=None,
        accumulate_weight_feature=None,
        batch_size=None,
        derived_features=None,
        input_is_substituted=False,
        progress_callback=None,
        series=None,
        train_weights_only=False,
        validate=True,
    ):
        """Train a trainee with sessions containing training cases."""

    @abstractmethod
    def impute(self, trainee_id, features=None, features_to_impute=None,
               batch_size=1):
        """Impute the missing values for the specified features_to_impute."""

    @abstractmethod
    def remove_cases(self, trainee_id, num_cases, *,
                     case_indices=None,
                     condition=None, condition_session=None,
                     distribute_weight_feature=None, precision=None,
                     preserve_session_data=False):
        """Remove training cases from a trainee."""

    @abstractmethod
    def move_cases(self, trainee_id, target_trainee_id, num_cases, *,
                   case_indices=None,
                   condition=None, condition_session=None,
                   precision=None, preserve_session_data=False):
        """Move training cases from one trainee to another."""

    @abstractmethod
    def edit_cases(self, trainee_id, feature_values, *, case_indices=None,
                   condition=None, condition_session=None, features=None,
                   num_cases=None, precision=None):
        """Edit feature values for the specified cases."""

    @abstractmethod
    def remove_series_store(self, trainee_id, series=None):
        """Clear stored series from trainee."""

    @abstractmethod
    def append_to_series_store(
        self,
        trainee_id,
        series,
        contexts,
        *,
        context_features=None
    ):
        """Append the specified contexts to a series store."""

    @abstractmethod
    def set_substitute_feature_values(self, trainee_id, substitution_value_map):
        """Set a substitution map for use in extended nominal generation."""

    @abstractmethod
    def get_substitute_feature_values(self, trainee_id, clear_on_get=True):
        """Get a substitution map for use in extended nominal generation."""

    @abstractmethod
    def set_feature_attributes(self, trainee_id, feature_attributes):
        """Set feature attributes for a trainee."""

    @abstractmethod
    def get_feature_attributes(self, trainee_id):
        """Get a dict of feature attributes."""

    @abstractmethod
    def get_sessions(self, search_terms=None):
        """Get list of all accessible sessions."""

    @abstractmethod
    def get_session(self, session_id):
        """Get session details."""

    @abstractmethod
    def update_session(self, session_id, *, metadata=None):
        """Update a session."""

    @abstractmethod
    def begin_session(self, name='default', metadata=None):
        """Begin a new session."""

    @abstractmethod
    def get_trainee_sessions(self, trainee_id):
        """Get the session ids of a trainee."""

    @abstractmethod
    def delete_trainee_session(self, trainee_id, session):
        """Delete a session from a trainee."""

    @abstractmethod
    def get_trainee_session_indices(self, trainee_id, session):
        """Get list of all session indices for a specified session."""

    @abstractmethod
    def get_trainee_session_training_indices(self, trainee_id, session):
        """Get list of all session training indices for a specified session."""

    @abstractmethod
    def get_feature_residuals(
        self, trainee_id, *,
        action_feature=None,
        robust=None,
        robust_hyperparameters=None,
        weight_feature=None,
    ):
        """Get cached feature residuals."""

    @abstractmethod
    def get_prediction_stats(
        self, trainee_id, *,
        action_feature=None,
        condition=None,
        num_cases=None,
        num_robust_influence_samples_per_case=None,
        precision=None,
        robust=None,
        robust_hyperparameters=None,
        stats=None,
        weight_feature=None,
    ):
        """Get cached feature prediction stats."""

    @abstractmethod
    def get_marginal_stats(
        self, trainee_id, *,
        condition=None,
        num_cases=None,
        precision=None,
        weight_feature=None,
    ):
        """Get marginal stats for all features."""

    @abstractmethod
    def react_series(
        self, trainee_id, *,
        action_features=None,
        actions=None,
        case_indices=None,
        contexts=None,
        context_features=None,
        continue_series=False,
        derived_action_features=None,
        derived_context_features=None,
        desired_conviction=None,
        details=None,
        feature_bounds_map=None,
        final_time_steps=None,
        generate_new_cases="no",
        init_time_steps=None,
        initial_features=None,
        initial_values=None,
        input_is_substituted=False,
        leave_case_out=None,
        max_series_lengths=None,
        new_case_threshold="min",
        num_series_to_generate=1,
        ordered_by_specified_features=False,
        output_new_series_ids=True,
        preserve_feature_values=None,
        progress_callback=None,
        series_context_features=None,
        series_context_values=None,
        series_id_tracking="fixed",
        series_stop_maps=None,
        substitute_output=True,
        suppress_warning=False,
        use_case_weights=False,
        use_regional_model_residuals=True,
        weight_feature=None
    ):
        """React in a series until a stop condition is met."""

    @abstractmethod
    def react_into_features(
        self, trainee_id, *,
        distance_contribution=False,
        familiarity_conviction_addition=False,
        familiarity_conviction_removal=False,
        features=None,
        p_value_of_addition=False,
        p_value_of_removal=False,
        similarity_conviction=False,
        use_case_weights=False,
        weight_feature=None
    ):
        """Calculate conviction and other data for the specified feature(s)."""

    @abstractmethod
    def react_into_trainee(
        self, trainee_id, *,
        action_feature=None,
        context_features=None,
        contributions=None,
        contributions_robust=None,
        hyperparameter_param_path=None,
        mda=None,
        mda_permutation=None,
        mda_robust=None,
        mda_robust_permutation=None,
        num_robust_influence_samples=None,
        num_robust_residual_samples=None,
        num_robust_influence_samples_per_case=None,
        num_samples=None,
        residuals=None,
        residuals_robust=None,
        sample_model_fraction=None,
        sub_model_size=None,
        use_case_weights=False,
        weight_feature=None,
    ):
        """Compute and cache specified feature interpretations."""

    @abstractmethod
    def react_group(
        self, trainee_id, *,
        distance_contributions=False,
        familiarity_conviction_addition=True,
        familiarity_conviction_removal=False,
        features=None,
        kl_divergence_addition=False,
        kl_divergence_removal=False,
        new_cases=None,
        p_value_of_addition=False,
        p_value_of_removal=False,
        trainees_to_compare=None,
        use_case_weights=False,
        weight_feature=None
    ):
        """Compute specified data for a **set** of cases."""

    @abstractmethod
    def react(
        self, trainee_id, *,
        action_features=None,
        actions=None,
        allow_nulls=False,
        case_indices=None,
        contexts=None,
        context_features=None,
        derived_action_features=None,
        derived_context_features=None,
        desired_conviction=None,
        details=None,
        feature_bounds_map=None,
        generate_new_cases="no",
        input_is_substituted=False,
        into_series_store=None,
        leave_case_out=None,
        new_case_threshold="min",
        num_cases_to_generate=1,
        ordered_by_specified_features=False,
        preserve_feature_values=None,
        progress_callback=None,
        substitute_output=True,
        suppress_warning=False,
        use_case_weights=False,
        use_regional_model_residuals=True,
        weight_feature=None
    ):
        """Send a `react` to the Howso engine."""

    @abstractmethod
    def evaluate(self, trainee_id, features_to_code_map, *, aggregation_code=None):
        """Evaluate custom code on case values within the trainee."""

    @abstractmethod
    def analyze(
        self,
        trainee_id,
        context_features=None,
        action_features=None,
        *,
        bypass_calculate_feature_residuals=None,
        bypass_calculate_feature_weights=None,
        bypass_hyperparameter_analysis=None,
        dt_values=None,
        inverse_residuals_as_weights=None,
        k_folds=None,
        k_values=None,
        num_analysis_samples=None,
        num_samples=None,
        analysis_sub_model_size=None,
        analyze_level=None,
        p_values=None,
        targeted_model=None,
        use_case_weights=None,
        use_deviations=None,
        weight_feature=None,
        **kwargs
    ):
        """Analyzes a trainee."""

    @abstractmethod
    def auto_analyze(self, trainee_id):
        """Auto-analyze the trainee model."""

    @abstractmethod
    def set_auto_analyze_params(
        self,
        trainee_id,
        auto_analyze_enabled=False,
        analyze_threshold=None,
        *,
        auto_analyze_limit_size=None,
        analyze_growth_factor=None,
        **kwargs
    ):
        """Set trainee parameters for auto analysis."""

    @abstractmethod
    def get_cases(self, trainee_id, session=None, case_indices=None,
                  indicate_imputed=False, features=None, condition=None,
                  num_cases=None, precision=None):
        """Retrieve cases from a trainee."""

    @abstractmethod
    def get_extreme_cases(self, trainee_id, num, sort_feature, features=None):
        """Get the extreme cases of a trainee for the given feature(s)."""

    @abstractmethod
    def get_num_training_cases(self, trainee_id):
        """Return the number of trained cases in the model."""

    @abstractmethod
    def get_feature_conviction(self, trainee_id, *,
                               features=None,
                               action_features=None,
                               familiarity_conviction_addition=True,
                               familiarity_conviction_removal=False,
                               weight_feature=None,
                               use_case_weights=False):
        """Get familiarity conviction for features in the model."""

    @abstractmethod
    def add_feature(self, trainee_id, feature, feature_value=None, *,
                    condition=None, condition_session=None,
                    feature_attributes=None, overwrite=False):
        """Add a feature to a trainee's model."""

    @abstractmethod
    def remove_feature(self, trainee_id, feature, *, condition=None,
                       condition_session=None):
        """Remove a feature from a trainee."""

    @abstractmethod
    def get_feature_mda(
        self, trainee_id, action_feature, *,
        permutation=None,
        robust=None,
        weight_feature=None,
    ):
        """Get cached feature Mean Decrease In Accuracy (MDA)."""

    @abstractmethod
    def get_feature_contributions(
        self, trainee_id, action_feature, *,
        robust=None,
        directional=False,
        weight_feature=None,
    ):
        """Get cached feature contributions."""

    @abstractmethod
    def get_pairwise_distances(self, trainee_id, features=None, *,
                               action_feature=None, from_case_indices=None,
                               from_values=None, to_case_indices=None,
                               to_values=None, use_case_weights=False,
                               weight_feature=None):
        """Compute pairwise distances between specified cases."""

    @abstractmethod
    def get_distances(self, trainee_id, features=None, *,
                      action_feature=None, case_indices=None,
                      feature_values=None, use_case_weights=False,
                      weight_feature=None):
        """Compute distances matrix for specified cases."""

    @abstractmethod
    def get_params(self, trainee_id, *, action_feature=None,
                   context_features=None, mode=None, weight_feature=None):
        """Get parameters used by the system."""

    @abstractmethod
    def set_params(self, trainee_id, params):
        """Set specific hyperparameters in the trainee."""

    def _resolve_trainee_id(self, trainee_id, *args, **kwargs):
        """Resolve trainee identifier."""
        return trainee_id

howso/client/base.py
howsoai/howso-engine-py
from collections.abc import Collection
import typing as t

if t.TYPE_CHECKING:
    from howso.openapi.models import Trainee


class TraineeCacheItem(t.TypedDict):
    """Type definition for trainee cache items."""

    trainee: "Trainee"
    user_defaults: t.Dict[str, t.Dict]


class TraineeCache(Collection):
    """Cache of trainee related information."""

    __slots__ = ('__dict__', )

    __marker = object()

    def set(self, trainee: "Trainee", **kwargs) -> None:
        """Set trainee in cache."""
        if trainee.id:
            self.__dict__.setdefault(trainee.id, {
                'user_defaults': {}
            })
            self.__dict__[trainee.id].update({
                'trainee': trainee,
                **kwargs
            })

    def get(self, trainee_id: str, default=__marker) -> "Trainee":
        """Get trainee instance by id."""
        try:
            return self.__dict__[trainee_id]['trainee']
        except KeyError:
            if default is self.__marker:
                raise
            return default

    def get_item(self, trainee_id: str, default=__marker) -> TraineeCacheItem:
        """Get trainee cache item by id."""
        try:
            return self.__dict__[trainee_id]
        except KeyError:
            if default is self.__marker:
                raise
            return default

    def get_user_defaults(self, trainee_id: str, user_id: str
                          ) -> t.Dict[str, t.Dict]:
        """Get user defaults for trainee."""
        user_defaults = self.__dict__[trainee_id]['user_defaults']
        try:
            return user_defaults[user_id]
        except KeyError:
            return user_defaults.setdefault(user_id, {})

    def discard(self, trainee_id: str) -> None:
        """Remove trainee from cache if exists."""
        try:
            del self.__dict__[trainee_id]
        except KeyError:
            pass

    def ids(self) -> t.Iterable[str]:
        """Return view of ids in cache."""
        return self.__dict__.keys()

    def items(self) -> t.Iterable[t.Tuple[str, TraineeCacheItem]]:
        """Return view items in cache."""
        return self.__dict__.items()

    def trainees(self) -> t.Iterator[t.Tuple[str, "Trainee"]]:
        """Return iterator to all trainee instances in cache."""
        for key, item in self.__dict__.items():
            yield (key, item['trainee'])

    def clear(self) -> None:
        """Clear the cache."""
        self.__dict__.clear()

    def __contains__(self, key: str) -> bool:
        """Return if trainee id is in cache."""
        try:
            self.__dict__[key]
        except KeyError:
            return False
        else:
            return True

    def __iter__(self) -> t.Iterator[str]:
        """Return iterator of the cached trainee ids."""
        return iter(self.__dict__)

    def __len__(self) -> int:
        """Return length of the cache."""
        return len(self.__dict__)

    def __str__(self) -> str:
        """Return string representation of the cache."""
        return str(self.__dict__)

howso/client/cache.py
howsoai/howso-engine-py
"""Primary Howso Client Class."""
from importlib import import_module
from os import environ
from os.path import expandvars
from pathlib import Path
from typing import Generator, Optional, Sequence, Tuple, Union
import warnings

from howso.client.base import AbstractHowsoClient
from howso.client.exceptions import HowsoConfigurationError
from howso.utilities import deep_update, UserFriendlyExit
import yaml


DEFAULT_CONFIG_FILE = 'howso.yml'
DEFAULT_CONFIG_FILE_ALT = 'howso.yaml'
DEFAULT_LEGACY_CONFIG_FILENAMES = [
    'diveplane.yml', 'diveplane.yaml',
    'config.yml', 'config.yaml',
]
CONFIG_FILE_ENV_VAR = 'HOWSO_CONFIG'
HOME_DIR_CONFIG_PATH = '.howso'
XDG_DIR_CONFIG_PATH = 'howso'
XDG_CONFIG_ENV_VAR = 'XDG_CONFIG_HOME'


def _check_isfile(file_paths: Sequence[Union[Path, str]]) -> Union[Path, None]:
    """
    Check if any of the given paths are files, returning the first one found.

    Parameters
    ----------
    file_paths: Sequence of Path or str
        A sequence of file paths (as Path or str)

    Returns
    -------
    Path or None:
        The first file_path in the given iterable that passes the `isfile`
        check.
    """
    for file_path in file_paths:
        file_path = Path(file_path)
        if file_path.is_file():
            return file_path

    return None


def get_configuration_path(config_path: Optional[Union[Path, str]] = None,  # noqa: C901
                           verbose: bool = False) -> Union[Path, None]:
    """
    Determine where the configuration is stored, if anywhere.

    If config_path is None, None will be returned.

    If a config_path is that is non-None, it will be processed as a YAML file,
    if the file does not exist at the provided path or there are parse errors,
    an exception will be raised.

    Parameters
    ----------
    config_path : str or None
        The given config_path.
    verbose : bool
        If True provides more verbose messaging. Default is false.

    Returns
    -------
    Path
        The found config_path or None

    Raises
    ------
    HowsoConfigurationError
        Raised if a `config_path` is provided but points to a non-existent
        file or the file is un-parsable as a YAML file.
    """
    if config_path is None:
        # Check HOWSO_CONFIG env variable
        user_dir = Path().home()
        xdg_config_home_not_abs_msg = (
            'The path set in the XDG_CONFIG_HOME environment variable'
            'is not absolute: "{0}". The specification for XDG_CONFIG_HOME '
            'variables requires the value to be an absolute path.'.format(
                environ.get(XDG_CONFIG_ENV_VAR)
            ))

        # Check if HOWSO_CONFIG env variable is set
        if environ.get(CONFIG_FILE_ENV_VAR) is not None:
            config_path = environ[CONFIG_FILE_ENV_VAR]
            if not Path(config_path).is_file():
                raise HowsoConfigurationError(
                    'The environment variable "{0}" was found, but it does '
                    'not point to Howso configuration '
                    'file.'.format(CONFIG_FILE_ENV_VAR))
            elif verbose:
                print(CONFIG_FILE_ENV_VAR + ' set to ' + config_path)
        # Check current working directory for howso.yml file
        elif Path(DEFAULT_CONFIG_FILE).is_file():
            config_path = DEFAULT_CONFIG_FILE
        # falling back to howso.yaml file
        elif Path(DEFAULT_CONFIG_FILE_ALT).is_file():
            config_path = DEFAULT_CONFIG_FILE_ALT
        # falling back to config.yml file
        elif config_path := _check_isfile(DEFAULT_LEGACY_CONFIG_FILENAMES):
            warnings.warn(
                f'Deprecated use of "{config_path}" file. '
                f'Please rename to "{DEFAULT_CONFIG_FILE}".')
        # Check for .yml config file in XDG_CONFIG_HOME directory, if configured
        elif (
            environ.get(XDG_CONFIG_ENV_VAR) is not None and
            Path(environ[XDG_CONFIG_ENV_VAR], XDG_DIR_CONFIG_PATH, DEFAULT_CONFIG_FILE).is_file()
        ):
            # Check if XDG_CONFIG_HOME is an absolute path.
            if not Path(expandvars(environ.get(XDG_CONFIG_ENV_VAR))).is_absolute():
                raise HowsoConfigurationError(xdg_config_home_not_abs_msg)
            config_path = Path(environ[XDG_CONFIG_ENV_VAR], XDG_DIR_CONFIG_PATH, DEFAULT_CONFIG_FILE)
        # Check for .yaml config file in XDG_CONFIG_HOME directory, if configured
        elif (
            environ.get(XDG_CONFIG_ENV_VAR) is not None and
            Path(environ[XDG_CONFIG_ENV_VAR], XDG_DIR_CONFIG_PATH, DEFAULT_CONFIG_FILE_ALT).is_file()
        ):
            # Check if XDG_CONFIG_HOME is an absolute path.
            if not Path(environ.get(XDG_CONFIG_ENV_VAR)).expanduser().is_absolute():
                raise HowsoConfigurationError(xdg_config_home_not_abs_msg)
            config_path = Path(environ[XDG_CONFIG_ENV_VAR], XDG_DIR_CONFIG_PATH, DEFAULT_CONFIG_FILE_ALT)
        # Check default home directory for config file
        elif Path(user_dir, HOME_DIR_CONFIG_PATH, DEFAULT_CONFIG_FILE).is_file():  # noqa
            config_path = Path(user_dir, HOME_DIR_CONFIG_PATH, DEFAULT_CONFIG_FILE)
        # falling back to howso.yaml file
        elif Path(user_dir, HOME_DIR_CONFIG_PATH, DEFAULT_CONFIG_FILE_ALT).is_file():  # noqa
            config_path = Path(user_dir, HOME_DIR_CONFIG_PATH, DEFAULT_CONFIG_FILE_ALT)
        # falling back to legacy filenames
        elif config_path := _check_isfile([
            Path(user_dir, HOME_DIR_CONFIG_PATH, file)
            for file in DEFAULT_LEGACY_CONFIG_FILENAMES
        ]):
            warnings.warn(
                f'Use of deprecated configuration file name at "{config_path}". '
                f'Please rename to "{DEFAULT_CONFIG_FILE}".')

    # Verify file in config_path parameter exists
    elif config_path and not Path(config_path).is_file():
        raise HowsoConfigurationError(
            "Specified configuration file was not found. Verify that the "
            "location of your configuration file matches the config parameter "
            "used when instantiating the client.")

    if verbose:
        if config_path:
            print(f'Using configuration at path: {config_path}')
        else:
            print('Using configuration-less defaults.')

    return config_path


def _gen_files_in_dir(directory: Path) -> Generator[Path, None, None]:
    """
    Recursively yield file (not directory) paths from the given `directory`.

    Parameters
    ----------
    directory : str or Path
        The directory to look within.

    Yields
    ------
    Path
        Paths to files within the given `directory` at any depth.
    """
    for node in directory.iterdir():
        if node.is_dir():
            yield from _gen_files_in_dir(node)
        else:
            yield node


def get_extras_configs(directory: Union[Path, str, None] = None) -> dict:
    """
    Accumulate and return any "extra" config found in resources or other path.

    Parameters
    ----------
    directory : str or Path, default None
        Optional. The directory to look within. This is given as the directory
        containing the `howso.yml` et al file. If not provided will default to
        a 'resources' directory, if found, in the howso namespace.

    Returns
    -------
    dict
        A dictionary containing a merged set of extras configurations found in
        the given `directory` or the default, which is <howso>/resources/.
    """
    if not directory:
        directory = Path(__file__).parent.parent.joinpath('resources')
    if not isinstance(directory, Path):
        directory = Path(directory)

    extras_config = {}

    extras_stems = ['extras', 'Extras', 'EXTRAS']
    extras_exts = ['.yml', '.yaml', '.YML', '.YAML']
    for node in _gen_files_in_dir(directory):
        if node.suffix in extras_exts and node.stem in extras_stems:
            try:
                with open(node, 'r') as config:
                    config_data = yaml.safe_load(config)
            except Exception:  # noqa: Deliberately broad
                raise
            else:
                extras_config = deep_update(extras_config, config_data)

    return extras_config


def get_howso_client_class(**kwargs) -> Tuple[type, dict]:  # noqa: C901
    """
    Return the appropriate AbstractHowsoClient subclass based on config.

    This is a "factory function" that, based on the given parameters, will
    decide which AbstractHowsoClient derivative to return.

    In the event that no configuration is found that indicates which client
    class to use, the HowsoDirectClient will be returned.

    Parameters
    ----------
    kwargs : dict
        config_path: str or None
            The path to a valid configuration file, or None.
        verbose : bool
            If True provides more verbose messaging. Default false.
        Any other kwargs. These will be passed to the client constructor along
        with `config_path` and `verbose`.

    Returns
    -------
    AbstractHowsoClient
        A resolved subclass of AbstractHowsoClient.
    dict
        Client extra kwargs.
    """
    config_path = kwargs.get('config_path', None)
    verbose = kwargs.get('verbose', False)

    kind_exit = UserFriendlyExit(verbose=verbose)
    config_data = None

    # Attempt to load and parse config.yaml.
    config_path = get_configuration_path(config_path, verbose) or ""
    if config_path:
        try:
            with open(config_path, 'r') as config:
                config_data = yaml.safe_load(config)
        except TypeError:
            # There is no config_path (None), which is OK.
            config_data = dict()
        except yaml.YAMLError as yaml_exception:
            kind_exit(f'Unable to parse the configuration file located at '
                      f'"{config_path}". Please verify the YAML syntax of '
                      f'this file and try again.', exception=yaml_exception)
        except (IOError, OSError) as exception:
            kind_exit(f'Error reading the configuration file located at '
                      f'"{config_path}". Check the file permissions and try '
                      f'again.', exception=exception)
        else:
            # Lowercase top-level `howso` key.
            if 'Howso' in config_data and 'howso' not in config_data:
                config_data['howso'] = config_data['Howso']
                del config_data['Howso']
    else:
        config_data = {}

    client_class = None

    # Check if the configuration file `config.yaml` contains the item
    # 'client' that is a valid, dotted-path to another sub-class of
    # AbstractHowsoClient. If so, instantiate that one and return it. This
    # provides an opportunity for customer-specific functionality and/or
    # authentication schemes, etc.
    try:
        custom_client = config_data['howso']['client']  # type: ignore
        # Split the dotted-path into "module" and the specific "class". For
        # example. `my_package.my_module.MyClass' would become
        # `custom_module_path` of `my_package.my_module` and
        # `custom_class_name` becomes `MyClass`.
        custom_module_path, custom_class_name = custom_client.rsplit('.', 1)
        # Attempt to load the module itself.
        custom_module = import_module(custom_module_path)
        # Set `client_class` to the actual class provided at the end of the
        # dotted-path.
        client_class = getattr(custom_module, custom_class_name)
        # Ensure that the `client_class` is a subclass of
        # AbstractHowsoClient.
        if not issubclass(client_class, AbstractHowsoClient):
            raise HowsoConfigurationError(
                'The provided client_class must be a subclass '
                'of AbstractHowsoClient.')
    except KeyError:
        # Looks like no attempt was made to override the default client class.
        # By passing here, we'll determine a default class to return
        pass
    except (AttributeError, ImportError, ModuleNotFoundError, ValueError) as exception:
        # User attempted to override the default client class, but there was
        # an error.
        kind_exit('The configuration at howso -> client, if provided, '
                  'should contain a valid dotted-path to a '
                  'subclass of AbstractHowsoClient.', exception=exception)
    except HowsoConfigurationError as exception:
        # User provided a dotted-path to a class, but it's not a subclass of
        # the AbstractHowsoClient
        kind_exit('The client configured in Howso -> client is not a '
                  'valid subclass of AbstractHowsoClient.',
                  exception=exception)

    # Determine default client if one is not set by the config
    if client_class is None:
        # Otherwise use the direct client
        from howso.direct import HowsoDirectClient
        client_class = HowsoDirectClient

    # customer-specific functionality and/or authentication schemes, etc.
    try:
        client_extra_params = config_data['howso']['client_extra_params']  # type: ignore
    except KeyError:
        # No extra params set - that is ok - let's move on
        client_extra_params = dict()

    if client_extra_params is None:
        client_extra_params = dict()
    elif not isinstance(client_extra_params, dict):
        kind_exit('The configuration at howso -> client_extra_params '
                  'should be defined as a dictionary.')

    # Add any "Extras"
    try:
        extras_config = get_extras_configs()
        client_extra_params = deep_update(
            client_extra_params,
            extras_config['howso']['client_extra_params']
        )
    except Exception:  # noqa: Deliberately broad
        pass

    if verbose:
        print("Instantiating %r" % client_class)

    return client_class, client_extra_params


def get_howso_client(**kwargs):
    """
    Return the appropriate AbstractHowsoClient subclass based on config.

    This is a "factory function" that, based on the given parameters, will
    decide which AbstractHowsoClient derivative to instantiate and return.

    Parameters
    ----------
    config_path: str or None, optional
        The path to a valid configuration file, or None
    verbose : bool, optional
        If True provides more verbose messaging. Default is false.
    kwargs : dict
        Additional client arguments. These will be passed to the client
        constructor along with `config_path` and `verbose`.

    Returns
    -------
    AbstractHowsoClient
        An instantiated subclass of AbstractHowsoClient.
    """
    client_class, client_params = get_howso_client_class(**kwargs)
    client_params.update(kwargs)
    return client_class(**client_params)


# For backwards compatibility, let this factory function assume the default
# client class name.
HowsoClient = get_howso_client

howso/client/client.py
howsoai/howso-engine-py
import os

from howso.client.exceptions import HowsoConfigurationError
import yaml

ENV_CREATE_MAX_WAIT_TIME = 'HOWSO_CLIENT_CREATE_MAX_WAIT_TIME'


class HowsoConfiguration:
    """
    Howso client configuration.

    Parameters
    ----------
    config_path : str
        The path to the user's howso.yml
    verbose : bool, default False
        Set verbose output.
    """

    def __init__(self, *args, config_path=None, verbose=False, **kwargs):
        """Initialize the configuration object."""
        super().__init__(*args, **kwargs)
        self.howso_config_path = config_path
        self.verbose = verbose

        if self.verbose:
            print(f'Using config file: {config_path}')

        try:
            with open(config_path, 'r') as config:
                self.user_config = yaml.safe_load(config)
        except yaml.YAMLError as yaml_exception:
            raise HowsoConfigurationError(
                'Unable to parse the configuration file located at '
                f'"{config_path}". Please verify the YAML syntax '
                'of this file and try again.'
            ) from yaml_exception
        except (IOError, OSError) as exception:
            raise HowsoConfigurationError(
                'Error reading the configuration file located at '
                f'"{config_path}". Check the file permissions and '
                'try again.'
            ) from exception

    def get_user_config_option(self, *args, default=None):
        """
        Retrieve a configuration option from the user's howso.yml settings.

        Parameters
        ----------
        args : str
            The path to the option in the configuration data.
        default : Any, default None
            The value to default to if not found.

        Returns
        -------
        Any
            The value of the option at the given path.
        """
        if len(args) == 0:
            raise AssertionError('At least one configuration option key '
                                 'is required.')
        option = self.user_config
        for arg in args:
            try:
                option = option[arg]
            except (KeyError, TypeError):
                return default
        return option

    def get_max_create_time(self, max_wait_time=None, *, default=30):
        """
        Retrieve the maximum time to wait for trainee to become available.

        The value is determined by (in this order):
        1. An explicitly set `max_wait_time` parameter value;
        2. An environment variable `HOWSO_CLIENT_CREATE_MAX_WAIT_TIME;
        3. A setting in the config.yaml file:
           Howso > options > create_max_wait_time; or
        4. the final default of 30 seconds.

        Parameters
        ----------
        max_wait_time : int, optional
            A user provided parameter value to use.

        Returns
        -------
        int or None
            The maximum time to wait in seconds. Or None for no max wait time.
        """
        # 1: Explicitly setting the `max_wait_time` parameter
        if max_wait_time is None:
            sentinel = object()

            # 2: ENV Variable named HOWSO_CLIENT_CREATE_MAX_WAIT_TIME
            env_value = os.environ.get(ENV_CREATE_MAX_WAIT_TIME,
                                       default=sentinel)
            if env_value != sentinel:
                try:
                    max_wait_time = float(env_value)  # type: ignore
                except (TypeError, ValueError):
                    # Any value that cannot be converted to a Float will be
                    # interpreted as `None`, which means to wait indefinitely.
                    max_wait_time = None
            else:

                # 3: config.yaml option
                config_value = self.get_user_config_option(
                    'howso', 'options', 'create_max_wait_time',
                    default=sentinel)
                if config_value != sentinel:
                    try:
                        max_wait_time = float(config_value)  # type: ignore
                    except (TypeError, ValueError):
                        # Any value that cannot be converted to a Float will
                        # be interpreted as `None`, which means to
                        # wait indefinitely.
                        max_wait_time = None
                else:
                    # 4. Final default
                    max_wait_time = default
        if max_wait_time == 0:
            # Zero signifies no maximum wait time
            max_wait_time = None
        return max_wait_time

howso/client/configuration.py
howsoai/howso-engine-py
import json

from howso.openapi.exceptions import UnauthorizedException


class HowsoError(Exception):
    """
    An exception raised when Howso encounters an error.

    Parameters
    ----------
    message : str
        The error message.
    code : str, optional
        The error code.
    url : str, optional
        An absolute URI that identifies the problem type.
    """

    message = None
    code = None
    url = None

    def __init__(self, message, code=None, url=None):
        """Initialize a HowsoError."""
        if code is None:
            code = "0"
        if url is None:
            url = "about:blank"
        self.message = message
        self.code = code
        self.url = url
        super().__init__((message, code, url))


class HowsoConfigurationError(HowsoError):
    """An error raised when the howso.yml options are misconfigured."""


class HowsoApiError(HowsoError):
    """
    An error raised by the Howso rest API.

    Parameters
    ----------
    message : str
        The error message.
    code : str, optional
        The problem type code.
    status : int, optional
        The HTTP status code.
    url : str, optional
        An absolute URI that identifies the problem type.
    """

    status = None

    def __init__(self, message, code=None, status=None, url=None):
        """Initialize a HowsoApiError."""
        if status is None:
            status = -1
        self.status = status
        super().__init__(message, code, url)

    @classmethod
    def from_openapi(cls, obj):
        """
        Build a HowsoApiError from OpenAPI error object.

        Parameters
        ----------
        obj : ApiException
            The OpenAPI error.

        Returns
        -------
        HowsoApiError
            The constructed error instance.
        """
        return cls.from_json(obj.body)

    @classmethod
    def from_json(cls, obj):
        """
        Build a HowsoApiError from API response json.

        Parameters
        ----------
        obj : str
            A json string.

        Returns
        -------
        HowsoApiError
            The constructed error instance.
        """
        return cls.from_dict(json.loads(obj))

    @classmethod
    def from_dict(cls, obj):
        """
        Build a HowsoApiError from API response.

        Parameters
        ----------
        obj : dict
            The error information.

        Returns
        -------
        HowsoApiError
            The constructed error instance.
        """
        if obj is None:
            obj = {}

        title = obj.get('title', '')
        detail = obj.get('detail', '')
        status = obj.get('status')
        code = obj.get('code')
        url = obj.get('type')

        # Build message string
        if not title and not detail:
            title = 'An unknown error occurred.'
        message = f"{title}".strip()
        if title and detail:
            if message[-1] not in [':', '.', ',', ';']:
                message += ":"
            message += " "
        message += f"{detail}".strip()

        return cls(message, code, status, url)


class HowsoAuthenticationError(HowsoApiError):
    """An error raised when the authentication API request fails."""

    @classmethod
    def from_openapi(cls, obj):
        """
        Build a HowsoApiError from OpenAPI error object.

        Parameters
        ----------
        obj : ApiException
            The OpenAPI error.

        Returns
        -------
        HowsoApiError
            The constructed error instance.
        """
        if isinstance(obj, UnauthorizedException):
            try:
                body = json.loads(obj.body)
                error = body.get('error', obj.status)
            except Exception:
                error = obj.status
            return cls(f'{obj.reason}: {error}', status=obj.status)
        else:
            return super().from_openapi(obj)


class HowsoNotUniqueError(HowsoError):
    """An error raised when an attempt to rename a trainee is unsuccessful."""


class HowsoTimeoutError(HowsoError):
    """An error raised when an operation times out."""


class HowsoWarning(UserWarning):
    """A warning raised from core output."""

howso/client/exceptions.py
howsoai/howso-engine-py
from .client import (  # noqa: F401
    HowsoPandasClient,
    HowsoPandasClientMixin,
)

__all__ = [
    "HowsoPandasClient",
    "HowsoPandasClientMixin",
]

howso/client/pandas/__init__.py
howsoai/howso-engine-py
from typing import (
    Dict,
    Iterable,
    List,
    Union,
)

from howso.client.client import get_howso_client_class
from howso.utilities import (
    build_react_series_df,
    deserialize_cases,
    format_dataframe,
)
from howso.utilities.internals import deserialize_to_dataframe
import pandas as pd
from pandas import DataFrame, Index


class HowsoPandasClientMixin:
    """
    Overrides Howso client methods to return data as pandas data types.

    Base: :class:`howso.client.AbstractHowsoClient`
    """

    def get_trainee_session_indices(self, *args, **kwargs) -> Index:
        """
        Base: :func:`howso.client.AbstractHowsoClient.get_trainee_session_indices`.

        Returns
        -------
        Index
            An index of the session indices for the requested session.
        """
        indices = super().get_trainee_session_indices(*args, **kwargs)
        if isinstance(indices, list):
            return pd.Index(indices, dtype='int64')
        return pd.Index([], dtype='int64')

    def get_trainee_session_training_indices(self, *args, **kwargs) -> Index:
        """
        Base: :func:`howso.client.AbstractHowsoClient.get_trainee_session_training_indices`.

        Returns
        -------
        Index
            An index of the session training indices for the requested session.
        """
        indices = super().get_trainee_session_training_indices(*args, **kwargs)
        if isinstance(indices, list):
            return pd.Index(indices, dtype='int64')
        return pd.Index([], dtype='int64')

    def react_group(self, *args, **kwargs) -> DataFrame:
        """
        Base: :func:`howso.client.AbstractHowsoClient.react_group`.

        Returns
        -------
        DataFrame
            A DataFrame of feature name columns to the conviction of grouped
            cases rows.
        """
        cases = super().react_group(*args, **kwargs)
        return deserialize_to_dataframe(cases)

    def get_cases(self, trainee_id: str, *args, **kwargs) -> DataFrame:
        """
        Base: :func:`howso.client.AbstractHowsoClient.get_cases`.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to get cases from.

        Returns
        -------
        DataFrame
            A DataFrame of feature name columns and case rows.
        """
        trainee_id = self._resolve_trainee_id(trainee_id)
        feature_attributes = self.trainee_cache.get(trainee_id).features
        response = super().get_cases(trainee_id, *args, **kwargs)
        return deserialize_cases(response.cases, response.features,
                                 feature_attributes)

    def get_extreme_cases(
        self,
        trainee_id: str,
        num: int,
        sort_feature: str,
        features: Iterable[str] = None
    ) -> DataFrame:
        """
        Base: :func:`howso.client.AbstractHowsoClient.get_extreme_cases`.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to retrieve extreme cases from.
        num : int
            The number of cases to get.
        sort_feature : str
            The feature name by which extreme cases are sorted by.
        features: iterable of str, optional
            An iterable of feature names to use when getting extreme cases.

        Returns
        -------
        DataFrame
            A DataFrame of feature name columns and extreme case rows.
        """
        trainee_id = self._resolve_trainee_id(trainee_id)
        feature_attributes = self.trainee_cache.get(trainee_id).features
        response = super().get_extreme_cases(trainee_id, num, sort_feature,
                                             features)
        return deserialize_cases(response.cases, response.features,
                                 feature_attributes)

    def get_feature_conviction(self, *args, **kwargs) -> DataFrame:
        """
        Base: :func:`howso.client.AbstractHowsoClient.get_feature_conviction`.

        Returns
        -------
        DataFrame
            A DataFrame containing the familiarity conviction rows to feature
            columns.
        """
        response = super().get_feature_conviction(*args, **kwargs)
        index = []
        rows = []
        if response.get('familiarity_conviction_addition'):
            index.append('familiarity_conviction_addition')
            rows.append(response['familiarity_conviction_addition'])
        if response.get('familiarity_conviction_removal'):
            index.append('familiarity_conviction_removal')
            rows.append(response['familiarity_conviction_removal'])
        return deserialize_to_dataframe(rows, index=index)

    def get_feature_residuals(self, *args, **kwargs) -> DataFrame:
        """
        Base: :func:`howso.client.AbstractHowsoClient.get_feature_residuals`.

        Returns
        -------
        DataFrame
            A DataFrame of feature name columns to residual value rows.
        """
        response = super().get_feature_residuals(*args, **kwargs)
        return deserialize_to_dataframe([response])

    def get_feature_mda(self, *args, **kwargs):
        """
        Base: :func:`howso.client.AbstractHowsoClient.get_feature_mda`.

        Returns
        -------
        DataFrame
            A DataFrame of context feature columns to mean decrease in
            accuracy value rows.
        """
        response = super().get_feature_mda(*args, **kwargs)
        return deserialize_to_dataframe([response])

    def get_feature_contributions(self, *args, **kwargs) -> DataFrame:
        """
        Base: :func:`howso.client.AbstractHowsoClient.get_feature_contributions`.

        Returns
        -------
        DataFrame
            A DataFrame of context feature columns to contribution value rows.
        """
        response = super().get_feature_contributions(*args, **kwargs)
        return deserialize_to_dataframe([response])

    def get_prediction_stats(self, *args, **kwargs) -> DataFrame:
        """
        Base: :func:`howso.client.AbstractHowsoClient.get_prediction_stats`.

        Returns
        -------
        DataFrame
            A DataFrame of feature name columns to statistic value rows.
        """
        response = super().get_prediction_stats(*args, **kwargs)
        return pd.DataFrame(response)

    def get_marginal_stats(self, *args, **kwargs) -> DataFrame:
        """
        Base: :func:`howso.client.AbstractHowsoClient.get_marginal_stats`.

        Returns
        -------
        DataFrame
            A DataFrame of feature name columns to statistic value rows.
        """
        response = super().get_marginal_stats(*args, **kwargs)
        return pd.DataFrame(response)

    def react_series(
            self, trainee_id: str, *args, series_index: str = '.series', **kwargs) -> DataFrame:
        """
        Base: :func:`howso.client.AbstractHowsoClient.react_series`.

        Parameters
        ----------
        trainee_id : str
            The trainee id.
        series_index : str, default ".series"
            When set to a string, will include the series index as a
            column in the returned DataFrame using the column name given.
            If set to None, no column will be added.

        Returns
        -------
        DataFrame
            A DataFrames of feature columns with series values as rows.
        """
        trainee_id = self._resolve_trainee_id(trainee_id)
        feature_attributes = self.trainee_cache.get(trainee_id).features
        response = super().react_series(trainee_id, *args, **kwargs)

        # If series_index is not a string, the intention is likely for it to not be included
        if not isinstance(series_index, str):
            series_index = None

        # Build response DataFrame
        df = build_react_series_df(response, series_index=series_index)

        return format_dataframe(df, feature_attributes)

    def react(self, trainee_id, *args, **kwargs) -> Dict[str, Union[DataFrame, Dict]]:
        """
        Base: :func:`howso.client.AbstractHowsoClient.react`.

        Returns
        -------
        dict
            A dictionary with keys `action` and `explanation`. Where `action`
            is a DataFrame of action_feature columns to action_value rows,
            and `explanation` is a dict with the requested audit data.
        """
        trainee_id = self._resolve_trainee_id(trainee_id)
        feature_attributes = self.trainee_cache.get(trainee_id).features
        response = super().react(trainee_id, *args, **kwargs)
        columns = response['explanation'].get('action_features')
        response['action'] = deserialize_cases(response['action'], columns,
                                               feature_attributes)
        return response

    def get_distances(self, *args, **kwargs) -> Dict[str, Union[DataFrame, List]]:
        """
        base: :func:`howso.client.AbstractHowsoClient.get_distances`.

        Returns
        -------
        dict
            A dict containing a matrix of computed distances and the list of
            corresponding case indices in the following format::

                {
                    'case_indices': [ session-indices ],
                    'distances': DataFrame[ distances ]
                }
        """
        response = super().get_distances(*args, **kwargs)
        response['distances'] = deserialize_to_dataframe(response['distances'])
        return response


def get_howso_pandas_client(**kwargs):
    """
    Return the appropriate AbstractHowsoClient subclass based on config.

    This is a "factory function" that, based on the given parameters, will
    decide which AbstractHowsoClient derivative to instantiate and return
    using the Pandas client mixin.

    Parameters
    ----------
    config_path: str or None, optional
        The path to a valid configuration file, or None
    verbose : bool, optional
        If True provides more verbose messaging. Default is false.
    kwargs : dict
        Additional client arguments. These will be passed to the client
        constructor along with `config_path` and `verbose`.

    Returns
    -------
    AbstractHowsoClient
        An instantiated subclass of AbstractHowsoClient constructed with
        the HowsoPandasClientMixin.
    """
    client_class, client_params = get_howso_client_class(**kwargs)

    # Construct the client class from the requested base client class and
    # the pandas mixin
    class HowsoPandasClient(HowsoPandasClientMixin, client_class):  # noqa
        pass

    client_params.update(kwargs)
    return HowsoPandasClient(**client_params)  # noqa


HowsoPandasClient = get_howso_pandas_client

howso/client/pandas/client.py
howsoai/howso-engine-py
import typing as t

if t.TYPE_CHECKING:
    from howso.openapi.models import Project

__all__ = [
    "ProjectClient",
]


@t.runtime_checkable
class ProjectClient(t.Protocol):
    """Protocol to define a Howso client that supports projects."""

    active_project: t.Optional["Project"]

    def switch_project(self, project_id: str) -> "Project":
        """Switch active project."""
        ...

    def create_project(self, name: str) -> "Project":
        """Create new project."""
        ...

    def update_project(self, project_id: str) -> "Project":
        """Update existing project."""
        ...

    def delete_project(self, project_id: str) -> None:
        """Delete a project."""
        ...

    def get_project(self, project_id: str) -> "Project":
        """Get existing project."""
        ...

    def get_projects(self, search_terms: t.Optional[str]) -> t.List["Project"]:
        """Search and list projects."""
        ...

howso/client/protocols.py
howsoai/howso-engine-py
import os


try:
    import howso.nominal_substitution as _  # noqa
except ImportError:
    NOMINAL_SUBSTITUTION_AVAILABLE = False
else:
    NOMINAL_SUBSTITUTION_AVAILABLE = True

howso/client/tests/__init__.py
howsoai/howso-engine-py
import json
import os
from pathlib import Path
from pprint import pprint
import sys
import uuid

import howso
from howso.client import HowsoClient
from howso.client.client import (
    _check_isfile,
    DEFAULT_LEGACY_CONFIG_FILENAMES,
    get_configuration_path,
    get_howso_client_class,
)
from howso.client.exceptions import (
    HowsoApiError,
    HowsoConfigurationError,
    HowsoError,
    HowsoTimeoutError,
)
from howso.client.protocols import ProjectClient
from howso.direct import HowsoDirectClient
from howso.openapi.models import (
    AsyncActionAccepted,
    AsyncActionStatus,
    PlatformVersion,
    Trainee,
    TrainResponse,
)
from howso.utilities.testing import get_configurationless_test_client, get_test_options
import numpy as np
import pandas as pd
import pytest
from semantic_version import Version


TEST_OPTIONS = get_test_options()

iris_file_path = (
    Path(howso.client.__file__).parent.parent
).joinpath("utilities/tests/data/iris.csv")
np.random.default_rng(2018)


def test_xdg_configuration_path(mocker):
    """Tests for expected behavior when XDG_CONFIG_HOME is set."""
    fake_xdg_config_path = '/tmp/xdg/path'
    fake_xdg_path = 'howso'
    target_path = Path(fake_xdg_config_path, fake_xdg_path, 'howso.yaml')
    names_to_remove = ('XDG_CONFIG_HOME', 'HOWSO_CONFIG')
    modified_environ = {
        k: v for k, v in os.environ.items()
        if k not in names_to_remove
    }
    modified_environ["XDG_CONFIG_HOME"] = fake_xdg_config_path

    # Only return true if our mock xdg path is set
    def _is_file(filepath):
        if filepath == target_path:
            return True
        return False

    mocker.patch.object(Path, 'is_file', autospec=True,
                        side_effect=_is_file)
    mocker.patch.dict(os.environ, modified_environ, clear=True)
    path = get_configuration_path()
    assert path == target_path


def test_xdg_not_abs_error(mocker):
    """Tests for expected error when XDG_CONFIG_HOME is not an absolute path."""
    fake_xdg_config_path = 'tmp/xdg/path'
    fake_xdg_path = 'howso'
    target_path = Path(fake_xdg_config_path, fake_xdg_path, 'howso.yaml')
    names_to_remove = ('XDG_CONFIG_HOME', 'HOWSO_CONFIG')
    modified_environ = {
        k: v for k, v in os.environ.items()
        if k not in names_to_remove
    }
    modified_environ["XDG_CONFIG_HOME"] = fake_xdg_config_path

    # Only return true if our mock xdg path is set
    def _is_file(filepath):
        if filepath == target_path:
            return True
        return False

    mocker.patch.object(Path, 'is_file', autospec=True,
                        side_effect=_is_file)
    mocker.patch.dict(os.environ, modified_environ, clear=True)
    with pytest.raises(HowsoConfigurationError) as exc:
        get_configuration_path()
    assert 'The path set in the XDG_CONFIG_HOME environment' in str(exc.value)


@pytest.mark.parametrize('existing_files, result', (
    (('config.yml', 'config.yaml'), 'config.yml'),
    (('config.yaml', 'config.yml'), 'config.yaml'),
    (('apple', 'banana', 'cherry'), None),
))
def test_check_isfile(mocker, existing_files, result):
    """Test that `_check_isfile` works as expected."""

    def _is_file(filepath):
        if str(filepath) in DEFAULT_LEGACY_CONFIG_FILENAMES:
            return True
        return False

    mocker.patch.object(Path, 'is_file', autospec=True,
                        side_effect=_is_file)

    assert str(_check_isfile(existing_files)) == str(result)


def test_get_howso_client_class(mocker):
    """Test that the HowsoDirectClient is returned if no config is provided."""
    mocker.patch('howso.client.client.get_configuration_path',
                 return_value=None)
    klass, _ = get_howso_client_class()
    assert klass == HowsoDirectClient


class TraineeBuilder:
    """Define a TraineeBuilder."""

    def __init__(self):
        """Initialize the TraineeBuilder."""
        self.trainees = []
        self.client = get_configurationless_test_client(client_class=HowsoClient, verbose=True)
        super().__init__()

    def __del__(self):
        """Delete all created trainees upon destruction of the builder."""
        self.delete_all()

    def create(self, trainee, overwrite_trainee=False):
        """Create a new trainee."""
        new_trainee = self.client.create_trainee(
            trainee, overwrite_trainee=overwrite_trainee)
        self.trainees.append(new_trainee)
        return new_trainee

    def copy(self, trainee_id, new_trainee_name=None, project_id=None):
        """Copy an existing trainee."""
        if isinstance(self.client, ProjectClient):
            new_trainee = self.client.copy_trainee(trainee_id, new_trainee_name,
                                                   project_id)
        else:
            new_trainee = self.client.copy_trainee(trainee_id, new_trainee_name)
        self.trainees.append(new_trainee)
        return new_trainee

    def delete(self, trainee):
        """Delete a single trainee created by the builder."""
        if trainee in self.trainees:
            try:
                self.client.delete_trainee(trainee.id)
                self.trainees.remove(trainee)
            except HowsoApiError as err:
                if err.status == 404:
                    self.trainees.remove(trainee)
                else:
                    raise

    def delete_all(self):
        """Delete all trainees created by the builder."""
        for trainee in reversed(self.trainees):
            self.delete(trainee)

    def unload_all(self):
        """Unload all trainees created by the builder."""
        for trainee in self.trainees:
            self.client.release_trainee_resources(trainee.id)


@pytest.fixture(name='trainee_builder', scope='module')
def trainee_builder():
    """Define a fixture."""
    return TraineeBuilder()


@pytest.fixture(scope="function")
def howso_client():
    """Return a non-shared client."""
    return get_configurationless_test_client(client_class=HowsoClient, verbose=True)


class TestDatetimeSerialization:
    """Define a DateTimeSerialization test class."""

    @classmethod
    def setup_class(cls):
        """Setup a test client to use for the whole class."""
        cls.client = get_configurationless_test_client(client_class=HowsoClient, verbose=True)

    @pytest.fixture(autouse=True)
    def trainee(self, trainee_builder):
        """Define a trainee fixture."""
        features = {'nom': {'type': 'nominal'},
                    'datetime': {'type': 'continuous',
                                 'date_time_format': '%Y-%m-%dT%H:%M:%S.%f'}
                    }
        trainee = Trainee(features=features,
                          default_action_features=['nom'],
                          default_context_features=['datetime'])
        trainee_builder.create(trainee, overwrite_trainee=True)
        try:
            yield trainee
        except Exception:
            raise
        finally:
            trainee_builder.delete(trainee)

    def test_train(self, trainee):
        """Test that train works as expected."""
        df = pd.DataFrame(data=np.asarray([
            ['a', 'b', 'c', 'd'],
            ['2020-9-12T9:09:09.123', '2020-10-12T10:10:10.123', '2020-12-12T12:12:12.123',
             '2020-10-11T11:11:11.123']
        ]).transpose(), columns=['nom', 'datetime'])
        self.client.train(trainee.id, cases=df.values.tolist(),
                          features=df.columns.tolist())
        case_response = self.client.get_cases(
            trainee.id, session=self.client.active_session.id)
        for case in case_response.cases:
            print(case)
        assert len(case_response.cases) == 4
        # datetime is fixed: zero-padded and has fractional seconds
        assert case_response.cases[0][1].rstrip("0") == '2020-09-12T09:09:09.123'

    def test_train_warn_fix(self, trainee_builder):
        """Test that train warns when it should."""
        features = {'nom': {'type': 'nominal'},
                    'datetime': {'type': 'continuous',
                                 'date_time_format': '%Y-%m-%dT%H:%M:%S'}
                    }
        trainee = Trainee(features=features,
                          default_action_features=['nom'],
                          default_context_features=['datetime'])
        trainee_builder.create(trainee, overwrite_trainee=True)
        df = pd.DataFrame(data=np.asarray([
            ['a', 'b', 'c', 'd'],
            # missing seconds in the provided values, don't match format
            ['2020-9-12T9:09', '2020-10-12T10:10', '2020-12-12T12:12',
             '2020-10-11T11:11']
        ]).transpose(), columns=['nom', 'datetime'])
        with pytest.warns(UserWarning) as warning_list:
            self.client.train(trainee.id, cases=df.values.tolist(),
                              features=df.columns.tolist())
            # There might be other UserWarnings but assert that at least one
            # of them has the correct message.
            assert any([str(warning.message) == (
                "datetime has values with incorrect datetime format, should "
                "be %Y-%m-%dT%H:%M:%S. This feature may not work properly.")
                for warning in warning_list])

        case_response = self.client.get_cases(
            trainee.id, session=self.client.active_session.id)
        for case in case_response.cases:
            print(case)
        assert len(case_response.cases) == 4
        # datetime is fixed: zero-padded and has seconds to adhere to format
        assert case_response.cases[0][1] == '2020-09-12T09:09:00'

    def test_train_warn_bad_feature(self, trainee_builder):
        """Test that train warns on bad features."""
        features = {'nom': {'type': 'nominal'},
                    'datetime': {'type': 'continuous',
                                 'date_time_format': '%H %Y'}
                    }
        trainee = Trainee(features=features,
                          default_action_features=['date_time_format'],
                          default_context_features=['nom'])
        trainee_builder.create(trainee, overwrite_trainee=True)
        df = pd.DataFrame(data=np.asarray([
            ['a', 'b', 'c', 'd'],
            # missing seconds in the provided values, don't match format
            ['2020-10-11T11:11', '2020-10-11T11:11', '2020-10-11T11:11',
             '2020-10-11T11:11']
        ]).transpose(), columns=['nom', 'datetime'])
        with pytest.warns(UserWarning) as warning_list:
            self.client.train(trainee.id, df.values.tolist(), df.columns.tolist())
            assert any([str(warning.message) == (
                "datetime has values with incorrect datetime format, should "
                "be %H %Y. This feature may not work properly.")
                for warning in warning_list])

        case_response = self.client.get_cases(
            trainee.id, session=self.client.active_session.id)
        for case in case_response.cases:
            print(case)
        assert len(case_response.cases) == 4
        # datetime is not fixed and the value returned doesn't match originals
        assert case_response.cases[0][1] != '2020-10-11T11:11'

    def test_react(self, trainee):
        """Test that react works as expected."""
        df = pd.DataFrame(data=np.asarray([
            ['a', 'b', 'c', 'd'],
            ['2020-9-12T9:09:09.123', '2020-10-12T10:10:10.333',
             '2020-12-12T12:12:12.444', '2020-10-11T11:11:11.222']
        ]).transpose(), columns=['nom', 'datetime'])
        self.client.train(trainee.id, cases=df.values.tolist(),
                          features=df.columns.tolist())
        response = self.client.react(trainee.id,
                                     contexts=[["2020-10-12T10:10:10.333"]])
        assert response['action'][0]['nom'] == "b"

        response = self.client.react(trainee.id, contexts=[["b"]],
                                     context_features=["nom"],
                                     action_features=["datetime"])
        assert "2020-10-12T10:10:10.333000" in response['action'][0]['datetime']


class TestClient:
    """Define a Client test class."""

    @classmethod
    def setup_class(cls):
        """Setup a test client to use for the whole class."""
        cls.client = get_configurationless_test_client(client_class=HowsoClient, verbose=True)

    @pytest.fixture(autouse=True)
    def trainee(self, trainee_builder):
        """Define a trainee fixture."""
        feats = {
            "penguin": {"type": "nominal"},
            "play": {"type": "nominal"},
        }
        actions = ['play']
        contexts = ['penguin']
        trainee_name = uuid.uuid4().hex
        trainee = Trainee(trainee_name, features=feats, default_action_features=actions,
                          default_context_features=contexts, metadata={'ttl': 600000})
        trainee_builder.create(trainee)
        try:
            yield trainee
        except Exception:
            raise
        finally:
            trainee_builder.delete(trainee)

    def _train(self, trainee):
        """
        Trains default cases.

        Parameters
        ----------
        trainee
        """
        # Add more cases
        cases = [['5', '6'],
                 ['7', '8'],
                 ['9', '10'],
                 ['11', '12'],
                 ['13', '14'],
                 ['15', '16'],
                 ['17', '18'],
                 ['19', '20'],
                 ['21', '22'],
                 ['23', '24'],
                 ['25', '26']]
        self.client.train(trainee.id, cases, features=['penguin', 'play'])

    def test_constructor(self):
        """Test that the client instantiates without issue."""
        assert self.client

    def test_get(self, trainee):
        """
        Test the /get endpoint.

        Parameters
        ----------
        trainee
        """
        returned_trainee = self.client.get_trainee(trainee.id)
        assert returned_trainee == trainee

    def test_update(self, trainee):
        """
        Test the PATCH / endpoint to update a trainee.

        Parameters
        ----------
        trainee
        """
        feats = {
            "dog": {"type": "nominal"},
            "cat": {"type": "continuous"}
        }
        actions = ['cat']
        contexts = ['dog']
        updated_trainee = Trainee(
            trainee.name,
            features=feats,
            default_action_features=actions,
            default_context_features=contexts,
            metadata={'date': 'now'}
        )
        updated_trainee = self.client.update_trainee(updated_trainee)
        trainee2 = self.client.get_trainee(trainee.id)
        assert trainee2.to_dict() == updated_trainee.to_dict()
        self.client.update_trainee(trainee)
        trainee3 = self.client.get_trainee(trainee.id)
        assert trainee3.to_dict() == trainee.to_dict()

    def test_train_and_react(self, trainee):
        """
        Test the /train and /react endpoint.

        Parameters
        ----------
        trainee
        """
        cases = [['1', '2'], ['3', '4']]
        self.client.train(trainee.id, cases, features=['penguin', 'play'])
        react_response = self.client.react(trainee.id, contexts=[['1']])
        assert react_response['action'][0]['play'] == '2'
        case_response = self.client.get_cases(
            trainee.id, session=self.client.active_session.id)
        for case in case_response.cases:
            print(case)
        assert len(case_response.cases) == 2

    def test_copy(self, trainee, trainee_builder):
        """
        Test the /copy/{trainee_id} endpoint.

        Parameters
        ----------
        trainee
        """
        new_trainee = trainee_builder.copy(trainee.id, trainee.name + "_copy")
        trainee_bob = self.client.get_trainee(new_trainee.id)
        orig = trainee.to_dict()
        copy_bob = trainee_bob.to_dict()
        assert orig['features'] == copy_bob['features']

    def test_trainee_conviction(self, trainee, trainee_builder):
        """
        Test the /copy/{trainee_id} endpoint.

        Parameters
        ----------
        trainee
        """
        cases = [['5', '6'],
                 ['7', '8'],
                 ['9', '10'],
                 ['11', '12'],
                 ['13', '14'],
                 ['15', '16'],
                 ['17', '18'],
                 ['19', '20'],
                 ['21', '22'],
                 ['23', '24'],
                 ['25', '26']]
        self.client.train(trainee.id, cases, features=['penguin', 'play'])
        trainee_copy = trainee_builder.copy(trainee.id, trainee.name + "_copy")
        cases2 = [['5', '6'],
                  ['7', '8'],
                  ['9', '10'],
                  ['11', '12'],
                  ['13', '14'],
                  ['15', '16'],
                  ['17', '18'],
                  ['19', '20'],
                  ['21', '22'],
                  ['23', '24'],
                  ['25', '26'],
                  ['27', '28']]
        self.client.train(
            trainee.name + "_copy", cases2, features=['penguin', 'play'])
        conviction = self.client.react_group(
            trainee.id, trainees_to_compare=[trainee_copy.id])
        assert conviction is not None

    def test_impute(self, trainee):
        """
        Test the /impute endpoint.

        Parameters
        ----------
        trainee
        """
        self._train(trainee)
        features = ['penguin', 'play']
        try:
            session = self.client.begin_session('impute')
            self.client.train(trainee.id, [['15', None]], features=features)
            self.client.impute(trainee.id, features=features, batch_size=2)
            imputed_session = self.client.get_cases(trainee.id, session.id,
                                                    indicate_imputed=True)
            assert imputed_session.cases[0][1] is not None
            assert '.imputed' in imputed_session.features
        finally:
            self.client.begin_session()  # Reset session

    def test_conviction_store(self, trainee):
        """
        Test the /conviction/store endpoint.

        Parameters
        ----------
        trainee
        """
        self._train(trainee)
        self.client.react_into_features(trainee.id, familiarity_conviction_addition=True)
        cases = self.client.get_cases(trainee.id,
                                      features=['play', 'penguin', 'familiarity_conviction_addition'],
                                      session=self.client.active_session.id)
        pprint(cases.__dict__)
        assert 'familiarity_conviction_addition' in cases.features

    def test_save(self, trainee):
        """
        Test the /save endpoint.

        Parameters
        ----------
        trainee
        """
        self.client.persist_trainee(trainee.id)

    def test_a_la_cart_data(self, trainee):
        """
        Test a-la-cart data.

        Systematically test a la cart options to ensure only the specified
        options are returned in the explanation data.

        Parameters
        ----------
        trainee : Trainee
            A trainee object to use.
        """
        # Add more cases
        cases = [['5', '6'],
                 ['7', '8'],
                 ['9', '10'],
                 ['11', '12'],
                 ['13', '14'],
                 ['15', '16'],
                 ['17', '18'],
                 ['19', '20'],
                 ['21', '22'],
                 ['23', '24'],
                 ['25', '26']]
        self.client.train(trainee.id, cases, features=['penguin', 'play'])

        details_sets = [
            (
                {'categorical_action_probabilities': True, },
                ['categorical_action_probabilities', ]
            ),
            (
                {'distance_contribution': True, },
                ['distance_contribution', ]
            ),
            (
                {'influential_cases': True, 'influential_cases_familiarity_convictions': True, },
                ['influential_cases', ]
            ),
            (
                {'num_boundary_cases': 1, 'boundary_cases_familiarity_conviction': True, },
                ['boundary_cases', ]
            ),
            (
                {'outlying_feature_values': True, },
                ['outlying_feature_values', ]
            ),
            (
                {'similarity_conviction': True, },
                ['similarity_conviction', ]
            ),
            (
                {'robust_computation': True, 'feature_residuals': True, },
                ['feature_residuals', ]
            ),
        ]
        for audit_detail_set, keys_to_expect in details_sets:
            response = self.client.react(trainee.id, contexts=[['1']],
                                         details=audit_detail_set)
            explanation = response['explanation']
            assert (all(explanation[key] is not None for key in keys_to_expect))

    def test_get_version(self):
        """Test get_version()."""
        version = self.client.get_version()
        assert version.api is not None
        assert version.client == howso.client.__version__

    def test_doublemax_to_infinity_translation(self):
        """Test the translation from Double.MAX_VALUE to Infinity."""
        from howso.utilities import replace_doublemax_with_infinity

        dat = {
            'familiarity_conviction': [sys.float_info.max],
            'influential_cases': [{
                'some_feature': {
                    'familiarity_conviction': sys.float_info.max
                }
            }]
        }
        dat = replace_doublemax_with_infinity(dat)
        assert (np.isinf(dat['familiarity_conviction'][0]))
        assert (np.isinf(dat['influential_cases'][0]['some_feature']['familiarity_conviction']))

    def test_number_overflow(self, trainee):
        """Test an exception is raised for a number that is too large."""
        # Should not raise
        self.client.train(trainee.id, [[1.8e307]], features=['penguin', 'play'])

        # Training with a number that is > 64bit should raise
        with pytest.raises(HowsoError):
            self.client.train(trainee.id, [[1.8e309, 2]],
                              features=['penguin', 'play'])

    def test_get_feature_mda(self, trainee, capsys):
        """
        Test get_feature_mda.

        Test for expected verbose output and expected return type when
        get_feature_mda is called.
        """
        self._train(trainee)
        self.client.react_into_trainee(
            trainee.id,
            mda=True,
            action_feature='play'
        )
        ret = self.client.get_feature_mda(
            trainee.id,
            action_feature='play')
        out, _ = capsys.readouterr()
        assert (f'Getting mean decrease in accuracy for trainee with '
                f'id: {trainee.id}') in out
        assert isinstance(ret, dict)
        assert len(ret) > 0

        with pytest.raises(HowsoError, match="Feature MDA for the"):
            ret = self.client.get_feature_mda(
                trainee.id,
                action_feature='invalid')

    def test_get_feature_residuals(self, trainee, capsys):
        """
        Test the /feature/residual endpoint in the python client.

        Parameters
        ----------
        trainee : Trainee
            A trainee object used or testing.
        """
        self._train(trainee)
        self.client.react_into_trainee(
            trainee_id=trainee.id,
            residuals=True,
            context_features=['penguin'],
            sample_model_fraction=1.0
        )
        ret = self.client.get_feature_residuals(trainee.id)
        assert (len(list(ret)) == 1)
        self.client.react_into_trainee(
            trainee_id=trainee.id,
            residuals=True,
            context_features=['penguin', 'play'],
            sample_model_fraction=1.0
        )
        ret = self.client.get_feature_residuals(trainee.id)
        assert len(list(ret)) == 2
        out, _ = capsys.readouterr()
        assert f'Getting feature residuals for trainee with id: {trainee.id}' in out


class TestBaseClient:
    """Define a BaseClient test class."""

    @classmethod
    def setup_class(cls):
        """Setup a test client to use for the whole class."""
        cls.client = get_configurationless_test_client(client_class=HowsoClient, verbose=True)

    @pytest.fixture(autouse=True)
    def trainee(self, trainee_builder):
        """Define a trainee fixture."""
        df = pd.read_csv(iris_file_path)
        header = list(df.columns)
        features = {header[0]: {'type': 'continuous'},
                    header[1]: {'type': 'continuous'},
                    header[2]: {'type': 'continuous'},
                    header[3]: {'type': 'continuous'},
                    header[4]: {'type': 'nominal'}
                    }
        trainee = Trainee(features=features,
                          default_action_features=header[-1:],
                          default_context_features=header[:-1])
        trainee_builder.create(trainee, overwrite_trainee=True)
        try:
            yield trainee
        except Exception:
            raise
        finally:
            trainee_builder.delete(trainee)

    def test_configuration_exception(self):
        """Test the Configuration Exception Class Constructor."""
        message = "test message"
        configuration_exception = HowsoConfigurationError(message=message)
        assert configuration_exception.message == message

    @pytest.mark.skipif('WEB' not in TEST_OPTIONS, reason='Web client only')
    def test_batch_train_verbose(self, trainee, capsys):
        """
        Test that batch_train works in verbose mode.

        Test the verbose output expected during the execution of batch_train.
        """
        df = pd.read_csv(iris_file_path)
        data = df.values
        test_percent = 0.2
        data_train = data[:int(len(data) * (1 - test_percent))]
        cases = data_train.tolist()
        self.client.train(trainee.id, cases, features=df.columns.tolist(),
                          batch_size=1000)
        out, _ = capsys.readouterr()
        assert (f'Batch training cases on trainee with '
                f'id: {trainee.id}') in out

    def test_impute_verbose(self, trainee, capsys):
        """Test the verbose output expected during the execution of impute."""
        self.client.impute(trainee.id)
        out, _ = capsys.readouterr()
        assert f'Imputing trainee with id: {trainee.id}' in out

    @pytest.mark.skipif('WEB' not in TEST_OPTIONS, reason='Web client only')
    def test_move_cases_exception(self, trainee):
        """
        Test that move_cases raises when expected.

        Checks for the expected exception when num_cases=1 is passed in
        move_cases.
        """
        condition = {"feature_name": None}
        with pytest.warns(UserWarning) as warn:
            self.client.move_cases(trainee.id, trainee.id, 1, condition=condition)
            assert str(warn[0].message) == ("move_cases has been removed from "
                                            "this version of Howso.")

    def test_remove_cases_verbose(self, trainee, capsys):
        """
        Test that remove_cases has verbose output when enabled.

        Test for verbose output expected when remove_cases is called with.
        """
        condition = {"feature_name": None}
        self.client.remove_cases(trainee.id, 1, condition=condition)
        out, _ = capsys.readouterr()
        assert f"Removing case(s) in trainee with id: {trainee.id}" in out

    @pytest.mark.skipif('WEB' not in TEST_OPTIONS, reason='Web client only')
    def test_move_cases_verbose(self, trainee, capsys):
        """
        Test that move_cases has verbose output when enabled.

        Tests for verbose output expected when move_cases is called.
        """
        condition = {"feature_name": None}
        self.client.move_cases(trainee.id, trainee.id, 1, condition=condition)
        out, _ = capsys.readouterr()
        assert ('"move_cases" does not exist for this version of '
                'Howso.') in out

    def test_update_trainee_verbose(self, trainee_builder, capsys):
        """
        Test that update_trainee has verbose output when enabled.

        Test for verbose output expected when update_trainee is called.
        """
        df = pd.read_csv(iris_file_path)
        header = list(df.columns)
        features = {header[0]: {'type': 'continuous'},
                    header[1]: {'type': 'continuous'},
                    header[2]: {'type': 'continuous'},
                    header[3]: {'type': 'continuous'},
                    header[4]: {'type': 'nominal'}
                    }
        trainee = Trainee(features=features,
                          default_action_features=header[-1:],
                          default_context_features=header[:-1])
        trainee_builder.create(trainee, overwrite_trainee=True)
        trainee.name = 'test-update-verbose'
        updated_trainee = self.client.update_trainee(trainee)
        assert trainee.name == updated_trainee.name
        assert updated_trainee.name == 'test-update-verbose'
        out, _ = capsys.readouterr()
        assert f'Updating trainee with id: {trainee.id}' in out

    def test_get_trainee_verbose(self, trainee, capsys):
        """
        Test that get_trainee has verbose output when enabled.

        Test for verbose output expected when get_trainee is called.
        """
        self.client.get_trainee(trainee.id)
        out, _ = capsys.readouterr()
        assert f'Getting trainee with id: {trainee.id}' in out

    def test_set_feature_attributes(self, trainee, capsys):
        """Test that set_feature_attributes works as expected."""
        attributes = {
            "length": {"type": "continuous", "decimal_places": 1},
            "width": {"type": "continuous", "significant_digits": 4},
            "degrees": {"type": "continuous", "cycle_length": 360},
            "class": {"type": "nominal"}
        }
        self.client.set_feature_attributes(trainee.id,
                                           feature_attributes=attributes)
        out, _ = capsys.readouterr()
        assert (f'Setting feature attributes for trainee '
                f'with id: {trainee.id}') in out

    def test_get_feature_attributes(self, trainee, capsys):
        """Test get_feature attributes returns the expected output."""
        attributes = {
            "length": {"type": "continuous", "decimal_places": 1},
            "width": {"type": "continuous", "significant_digits": 4},
            "degrees": {"type": "continuous", "cycle_length": 360},
            "class": {"type": "nominal"}
        }

        self.client.set_feature_attributes(trainee.id,
                                           feature_attributes=attributes)
        output = self.client.get_feature_attributes(trainee.id)
        out, _ = capsys.readouterr()
        assert (f'Getting feature attributes from trainee '
                f'with id: {trainee.id}') in out
        assert output == attributes

    def test_get_trainee_sessions_verbose(self, trainee, capsys):
        """
        Test that get_trainee_sessions has verbose output when enabled.

        Test for verbose output expected when get_trainee_sessions is called.
        """
        self.client.get_trainee_sessions(trainee.id)
        out, _ = capsys.readouterr()
        assert f'Getting sessions from trainee with id: {trainee.id}' in out

    def test_analyze_verbose(self, trainee, capsys):
        """Test for verbose output expected when analyze is called."""
        context_features = ['class']
        self.client.analyze(trainee.id, context_features)
        out, _ = capsys.readouterr()
        assert f'Analyzing trainee with id: {trainee.id}' in out
        assert 'Analyzing trainee with parameters: ' in out

    @pytest.mark.skipif('WEB' not in TEST_OPTIONS, reason='Web client only')
    def test_wait_for_action_exception(self, mocker):
        """
        Test that wait_for_action raises when expected.

        Tests for expected exception to be raised when the max_wait_time
        keyword argument is set and time runs over.
        """
        mock_action_accepted = AsyncActionAccepted(
            action_id='test123',
            operation_type='react'
        )
        mock_action_status = AsyncActionStatus(
            action_id='test123',
            status='pending',
            operation_type='react'
        )
        mocker.patch(
            'howso.openapi.api.TaskOperationsApi.get_action_output',
            return_value=mock_action_status)

        with pytest.raises(HowsoTimeoutError) as exc:
            self.client.api_client.wait_for_action(
                mock_action_accepted, max_wait_time=0.5)
        expected_msg = (
            "Operation 'react' exceeded max wait time of 0.5 seconds")
        assert expected_msg in str(exc.value)

    def test_acquire_trainee_resources_verbose(self, trainee, capsys):
        """
        Test that acquire_trainee_resources is verbose when enabled.

        Test for the verbose output expected when acquiring trainee resources.
        """
        self.client.persist_trainee(trainee.id)
        self.client.acquire_trainee_resources(trainee.id)
        out, _ = capsys.readouterr()
        assert f'Acquiring resources for trainee with id: {trainee.id}' in out

    def test_save_trainee_verbose(self, trainee, capsys):
        """
        Test that save_trainee is verbose when enabled.

        Test for the verbose output expected when persist_trainee is called.
        """
        self.client.persist_trainee(trainee.id)
        out, _ = capsys.readouterr()
        assert f'Saving trainee with id: {trainee.id}' in out

    def test_release_trainee_resources_verbose(self, trainee, capsys):
        """
        Test that release_trainee_resources is verbose when enabled.

        Test for the verbose output expected when releasing trainee resources.
        """
        self.client.release_trainee_resources(trainee.id)
        out, _ = capsys.readouterr()
        assert f'Releasing resources for trainee with id: {trainee.id}' in out

    def copy_trainee_verbose(self, trainee, trainee_builder, capsys):
        """
        Test that copy_trainee is verbose when enabled.

        Test for the verbose output expected when copy_trainee is called.
        """
        trainee_builder.copy(trainee.id, f'new_{trainee.id}')
        out, _ = capsys.readouterr()
        assert f'Copying trainee {trainee.id} to new_{trainee.id}' in out

    def test_delete_trainee_verbose(self, trainee, capsys):
        """
        Test that delete_trainee is verbose when enabled.

        Test for the verbose output expected when delete_trainee is called.
        """
        self.client.delete_trainee(trainee.id)
        out, _ = capsys.readouterr()
        assert 'Deleting trainee' in out

    def test_remove_cases_exception(self, trainee):
        """
        Test that remove_cases raises when expected.

        Test for the expected exception when num_cases=0 is passed into
        remove cases. (num_cases must be a value greater than 0).
        """
        condition = {"feature_name": None}
        with pytest.raises(ValueError) as exc:
            self.client.remove_cases(trainee.id, 0, condition=condition)
        assert str(exc.value) == 'num_cases must be a value greater than 0'

    def test_react_exception(self, trainee):
        """
        Test that react raises when expected.

        Test for the expected Exception when contexts and case_indices are not
        specified when calling react.
        """
        with pytest.raises((ValueError, HowsoApiError)) as exc:
            self.client.react(trainee.id, desired_conviction=None, contexts=None)
        msg = ("If `contexts` are not specified, both `case_indices` and "
               "`preserve_feature_values` must be specified.")
        assert msg in str(exc.value)

    def test_get_num_training_cases(self, trainee):
        """Test the that output is the expected type: int."""
        number_cases = self.client.get_num_training_cases(trainee.id)
        assert isinstance(number_cases, int)

    def test_react_group_exception(self, trainee):
        """
        Test that react_group raises when expected.

        Test the expected exception is raised when react_group
        gets parameters trainee_id and trainee_to_compare passed in as None.
        """
        with pytest.raises(ValueError) as exc:
            self.client.react_group(trainee.id)
        assert str(exc.value) == ("Either `new_cases` or `trainees_to_compare` "
                                  "must be provided.")

    def test_react_group_trainee_compare_verbose(self, trainee, capsys):
        """
        Test that react_group_trainee_compare is verbose when enabled.

        Test. the verbose output expected when react_group w/ trainee
        is called.
        """
        self.client.react_group(trainee.id, trainees_to_compare=[trainee.id])
        out, _ = capsys.readouterr()
        assert 'Reacting to a set of cases on trainee with id' in out

    def test_react_into_features_verbose(self, trainee, capsys):
        """
        Test that react_into_features is verbose when enabled.

        Test the verbose output expected when react_into_features is called.
        """
        self.client.react_into_features(trainee.id, familiarity_conviction_addition=True)
        out, _ = capsys.readouterr()
        assert ('Reacting into features on trainee with id') in out

    def test_get_feature_conviction_verbose(self, trainee, capsys):
        """
        Test that get_feature_conviction is verbose when enabled.

        Test for the verbose output expected when get_feature_conviction
        is called.
        """
        self.client.get_feature_conviction(trainee.id)
        out, _ = capsys.readouterr()
        assert 'Getting conviction of features for trainee with id' in out

    def test_get_params_verbose(self, trainee, capsys):
        """
        Test that get_params is verbose when enabled.

        Test for the verbose output expected when get_params is called.
        """
        self.client.get_params(trainee.id)
        out, _ = capsys.readouterr()
        assert (f'Getting model attributes from trainee with '
                f'id: {trainee.id}') in out

    @pytest.mark.parametrize('params', (
        {"hyperparameter_map": {
            "f1.f2.f3.": {
                ".targetless": {
                    "robust": {
                        ".none": {
                            "dt": -1, "p": .1, "k": 8
                        }
                    }
                }
            }
        }},
    ))
    def test_set_params_verbose(self, trainee, capsys, params):
        """Test for the verbose output expected when set_params is called."""
        self.client.set_params(trainee.id, params)
        out, _ = capsys.readouterr()
        assert (f'Setting model attributes for trainee with '
                f'id: {trainee.id}') in out

    def test_set_and_get_params(self, trainee, trainee_builder):
        """Test for set_params and get_params functionality."""
        param_map = {"hyperparameter_map": {
            "petal_length": {
                "sepal_length.sepal_width.": {
                    "robust": {
                        ".none": {
                            "dt": -1, "p": .1, "k": 2
                        }
                    }
                }
            }
        }}
        new_cases = [[1, 2, 3, 4, 5],
                     [4, 5, 6, 7, 8],
                     [7, 8, 9, 10, 11],
                     [1, 2, 3, 4, 5]]
        features = ['sepal_length', 'sepal_width', 'petal_length',
                    'petal_width', 'class']

        self.client.train(trainee.id, new_cases, features=features)
        self.client.set_params(trainee.id, param_map)

        # get a prediction with the set parameters
        first_pred = self.client.react(
            trainee.id,
            contexts=[2, 2],
            context_features=['sepal_length', 'sepal_width'],
            action_features=['petal_length'],
        )['action'][0]['petal_length']

        # create another trainee
        other_trainee = Trainee(
            features={"sepal_length": {'type': 'continuous'},
                      "sepal_width": {'type': 'continuous'},
                      "petal_length": {'type': 'continuous'},
                      "petal_width": {'type': 'continuous'},
                      "class": {'type': 'nominal'}},
            default_action_features=features[-1:],
            default_context_features=features[:-1]
        )
        trainee_builder.create(other_trainee, overwrite_trainee=True)
        other_trainee = self.client.update_trainee(other_trainee)
        self.client.train(other_trainee.id, new_cases, features=features)

        # make a prediction on the same case, prediction should be different
        second_pred = self.client.react(
            other_trainee.id,
            contexts=[2, 2],
            context_features=['sepal_length', 'sepal_width'],
            action_features=['petal_length'],
        )['action'][0]['petal_length']
        assert first_pred != second_pred

        # align parameters, make another prediction that should be the same
        self.client.set_params(other_trainee.id, param_map)
        third_pred = self.client.react(
            other_trainee.id,
            contexts=[2, 2],
            context_features=['sepal_length', 'sepal_width'],
            action_features=['petal_length'],
        )['action'][0]['petal_length']
        assert first_pred == third_pred

        # verify that both trainees have the same hyperparameter_map now
        first_params = self.client.get_params(trainee.id)
        second_params = self.client.get_params(other_trainee.id)
        assert first_params['hyperparameter_map'] == second_params['hyperparameter_map']

    def test_get_specific_hyperparameters(self, trainee):
        """Test to verify parameters of get_params are functional."""
        param_map = {"hyperparameter_map": {
            "petal_length": {
                "sepal_length.sepal_width.": {
                    "robust": {
                        ".none": {
                            "dt": -1, "p": .1, "k": 2
                        }
                    }
                }
            },
            ".targetless": {
                "sepal_length.sepal_width.": {
                    "robust": {
                        ".none": {
                            "dt": -1, "p": .5, "k": 3
                        }
                    }
                }
            }
        }}

        self.client.set_params(trainee.id, param_map)

        params = self.client.get_params(trainee.id, action_feature='.targetless')
        assert params['hyperparameter_map'] == {"dt": -1, "p": .5, "k": 3}

        params = self.client.get_params(trainee.id, action_feature='petal_length')
        assert params['hyperparameter_map'] == {"dt": -1, "p": .1, "k": 2}

    def test_get_configuration_path_exceptions(self):
        """
        Test that get_configuration_path raises when expected.

        Test for multiple expected when there are issues with the path passed
        in or the configuration yaml file.
        """
        with pytest.raises(HowsoConfigurationError) as exc:
            get_configuration_path(config_path='Fake/Path')
        assert 'Specified configuration file was not found' in str(exc.value)

    def test_base_client_initializer_exception(self):
        """
        Test that the base client initializer raises when expected.

        Tests for the expected exception when an instance of the
        HowsoClient is created with an invalid path
        """
        with pytest.raises(HowsoConfigurationError) as exc:
            HowsoClient(config_path="Fake/Path", verbose=True)
        assert "Specified configuration file was not found" in str(exc)

    @pytest.mark.skipif('WEB' not in TEST_OPTIONS, reason='Web client only')
    def test_train_analyzes(self, howso_client, trainee, mocker):
        """
        Test that traine analyzes when expected.

        Test that auto_analyze is also called when test_train is called
        using mocking.
        """
        df = pd.read_csv(iris_file_path)
        data = df.values
        # Ensure we don't trigger batch_train conditions...
        data_train = data[:howso_client._train_batch_threshold]
        return_value = TrainResponse(status="analyze")
        howso_client._train = mocker.Mock(return_value=return_value)

        spy = mocker.spy(howso_client, 'auto_analyze')
        howso_client.train(trainee.id, cases=data_train,
                           features=df.columns.tolist(), batch_size=None)
        spy.assert_called_once_with(trainee.id)

    def test_set_get_substitute_feature_values(self, trainee, capsys):
        """
        Test that set_substitute_feature_values works as expected.

        Test the functionality of both set_substitute_feature_values and
        get_feature_values as well as checks for expected verbose output.
        """
        df = pd.read_csv(iris_file_path)
        header = list(df.columns)
        substitution_value_map = {header[0]: {'type': 'continuous'},
                                  header[1]: {'type': 'continuous'},
                                  header[2]: {'type': 'continuous'},
                                  header[3]: {'type': 'continuous'},
                                  header[4]: {'type': 'nominal'}
                                  }
        self.client.set_substitute_feature_values(
            trainee.id, substitution_value_map)
        out, _ = capsys.readouterr()
        assert (f'Setting substitute feature values for '
                f'trainee with id: {trainee.id}') in out
        ret = self.client.get_substitute_feature_values(trainee.id)
        out, _ = capsys.readouterr()
        assert (f'Getting substitute feature values from trainee with '
                f'id: {trainee.id}') in out
        assert ret == substitution_value_map
        ret = self.client.get_substitute_feature_values(trainee.id)
        assert ret == {}

    def test_set_get_feature_attributes(self, trainee, capsys):
        """
        Test that set_- and get_feature_attributes work as expected.

        Test the the functionality of both set_substitute_feature_attributes
        and get_feature_attributes as well as checks for expected verbose
        output.
        """
        feats = {
            "length": {"type": "continuous", "decimal_places": 1},
            "width": {"type": "continuous", "significant_digits": 4},
            "degrees": {"type": "continuous", "cycle_length": 360},
            "class": {"type": "nominal"}
        }

        self.client.set_feature_attributes(trainee.id, feature_attributes=feats)
        out, _ = capsys.readouterr()
        assert (f'Setting feature attributes for trainee '
                f'with id: {trainee.id}') in out

        ret = self.client.get_feature_attributes(trainee.id)
        out, _ = capsys.readouterr()
        assert (f'Getting feature attributes from trainee with '
                f'id: {trainee.id}') in out
        assert ret == feats

    @pytest.mark.skipif('WEB' not in TEST_OPTIONS, reason='Web client only')
    def test_wait_for_action(self, capsys, mocker):
        """Test for expected exception and verbose output."""
        max_wait_time = 1.0
        operation_type = 'testing'

        mock_action_accepted = AsyncActionAccepted(
            action_id='test123',
            operation_type=operation_type
        )
        mock_action_status = AsyncActionStatus(
            action_id='test123',
            status='pending',
            operation_type=operation_type
        )
        mocker.patch(
            'howso.openapi.api.TaskOperationsApi.get_action_output',
            return_value=mock_action_status)

        with pytest.raises(HowsoTimeoutError) as exc:
            self.client.api_client.wait_for_action(
                mock_action_accepted, max_wait_time=max_wait_time)
        expected_msg = (f"Operation '{operation_type}' exceeded max wait time "
                        f"of {max_wait_time} seconds")
        assert expected_msg in str(exc.value)
        max_wait_time = 3

        with pytest.raises(HowsoTimeoutError) as exc:
            self.client.api_client.wait_for_action(
                mock_action_accepted, max_wait_time=max_wait_time)
        out, _ = capsys.readouterr()
        assert f"Operation '{operation_type}' is pending, waiting " in out

    def test_react_exceptions(self, trainee):
        """
        Test that react raises when expected.

        Test for expected exceptions when react is called with certain values
        passed in.
        """
        df = pd.read_csv(iris_file_path)
        features = list(df.columns)
        num_cases_to_generate = 10
        with pytest.raises(HowsoError) as exc:
            self.client.react(trainee.id,
                              desired_conviction=1.0,
                              preserve_feature_values=features,
                              case_indices=[('test', 1), ('test', 2)],
                              num_cases_to_generate=num_cases_to_generate)
        assert (
            "The number of `case_indices` provided does not match "
            "the number of cases to generate."
        ) in str(exc.value)
        num_cases_to_generate = 1
        with pytest.raises(HowsoError) as exc:
            self.client.react(trainee.id,
                              desired_conviction=1.0,
                              preserve_feature_values=features,
                              num_cases_to_generate=num_cases_to_generate,
                              contexts=[['test']])
            assert ("The number of provided context values in "
                    "`contexts` does not match the number of features in "
                    "`context_features`.") in str(exc.value)

    def test_get_cases_warning(self, trainee, capsys):
        """
        Test that get_cases issues warning when expected.

        Test for expected warning when get_cases is called without passing in
        a session id.
        """
        expected_message = ('Calling get_cases without session id does '
                            'not guarantee case order.')
        with pytest.warns(Warning, match=expected_message):
            self.client.get_cases(trainee.id)
            out, _ = capsys.readouterr()
            assert 'Retrieving cases.' in out

    def test_react_group(self, trainee, capsys):
        """
        Test that react_group works as expected.

        Tests for expected verbose output and expected return type (list) when
        get_case_familiarity conviction is called.
        """
        new_cases = [[[1, 2, 3, 4, 5],
                      [4, 5, 6, 7, 8],
                      [7, 8, 9, 10, 11]],
                     [[1, 2, 3, 4, 5]]]
        features = ['sepal_length', 'sepal_width', 'petal_length',
                    'petal_width', 'class']
        ret = self.client.react_group(
            trainee.id, new_cases=new_cases, features=features)
        out, _ = capsys.readouterr()
        assert type(ret) == dict

        df = pd.DataFrame([[1, 2, 4, 4, 4]], columns=features)
        new_cases = [df]
        ret = self.client.react_group(
            trainee.id, new_cases=new_cases, features=features)
        out, _ = capsys.readouterr()
        assert isinstance(ret, dict)

        # 2d list of cases, which is invalid
        new_cases = [[1, 2, 3, 4, 5],
                     [4, 5, 6, 7, 8],
                     [7, 8, 9, 10, 11]]
        with pytest.raises(ValueError) as exc:
            ret = self.client.react_group(
                trainee.id, new_cases=new_cases, features=features)
        assert (
            "Improper shape of `new_cases` values passed. "
            "`new_cases` must be a 3d list of object."
        ) in str(exc.value)

    def test_marginal_stats(self, trainee):
        """Test for get_marginal_stats and its parameters."""
        new_cases = [[1, 2, 3, 4, 0],
                     [4, 6, 6, 7, 1],
                     [6, 8, 9, 9, 1],
                     [1, 2, 3, 4, 2]]
        features = ['sepal_length', 'sepal_width', 'petal_length',
                    'petal_width', 'class']
        self.client.train(trainee.id, new_cases, features=features)

        marginal_stats = self.client.get_marginal_stats(trainee.id)
        assert marginal_stats['sepal_length']['mean'] == 3.0
        assert marginal_stats['sepal_width']['mean'] == 4.5
        assert marginal_stats['sepal_width']['count'] == 4

        conditional_marginal_stats = self.client.get_marginal_stats(
            trainee.id,
            condition={"class": '1'},
        )
        assert conditional_marginal_stats['class']['count'] == 2
        assert conditional_marginal_stats['petal_width']['mean'] == 8

    def test_remove_feature_verbose(self, trainee, capsys):
        """Test for expected verbose output when remove_feature is called."""
        feature = 'test'
        self.client.remove_feature(trainee.id, feature=feature)
        out, _ = capsys.readouterr()
        assert (f'Removing feature "{feature}" for trainee with id: '
                f'{trainee.id}') in out

    @pytest.mark.parametrize(
        'json_map, expected_output',
        [
            (
                json.loads('{"message":"java.lang.IllegalStateException: '
                           'Expected a", "code":0}'),
                'Howso Error: HowsoError /0'
            ),
            (
                json.loads('{"message":"java.lang.IllegalStateException: '
                           'Expected a "}'),
                'Howso Error:  HowsoError /-1'
            ),
            (
                json.loads('{"test":"general"}'),
                'General Exception HowsoError'
            )
        ]
    )
    def test_raise_howso_error(self, capsys, json_map, expected_output):
        """Test for various expected error outputs with various json_maps."""
        with pytest.raises(Exception):
            self.client._raise_howso_error(json_map)
            out, _ = capsys.readouterr()
            assert expected_output in out

    def test_get_configuration_path(self, mocker):
        """
        Test that get_configuration_path works as expected.

        Tests for expected output when the mocked functions return None and
        True respectively.
        """
        mocker.patch('os.environ.get', return_value=None)
        mocker.patch.object(Path, 'is_file', return_value=True)
        path = get_configuration_path()
        assert str(path) == 'howso.yml'

    @pytest.mark.skipif('WEB' not in TEST_OPTIONS, reason='Web client only')
    @pytest.mark.parametrize('client_api_version, api_version, outcome', (
        ('2.0.0', '3.4.5', False),  # Client version too low
        ('4.0.0', '3.4.5', False),  # Client version too high
        ('4.0.0', None, None),  # Unknown version compatibility
        ('3.0.0', '3.4.5', True),  # Valid client version
    ))
    def test_check_service_compatibility(self, mocker, client_api_version,
                                         api_version, outcome):
        """
        Test that check_service_availablity works as expected.

        Ensure that the check_service_compatibility() works for a range of
        scenarios.
        """
        version_response = PlatformVersion(
            platform='1.0.0',
            api=api_version
        )
        mocker.patch('howso.client.client.client_api_version',
                     client_api_version)
        mocker.patch.object(self.client, 'get_version',
                            return_value=version_response)

        def _has_warning(warnings, msg):
            return any([
                msg in str(warning.message) for warning in warnings
            ])

        if outcome:
            assert self.client._check_service_compatibility()
        else:
            with pytest.warns(UserWarning) as warning_list:
                assert not self.client._check_service_compatibility()

                if api_version is None:
                    assert len(warning_list)
                    assert _has_warning(warning_list, "Proceed with caution")
                elif int(Version(client_api_version).major) < int(Version(api_version).major):
                    assert len(warning_list)
                    assert _has_warning(warning_list,
                                        'Please upgrade the client software to a '
                                        'newer version.')
                elif int(Version(client_api_version).major) > int(Version(api_version).major):
                    assert len(warning_list)
                    assert _has_warning(warning_list,
                                        'Please upgrade the server, or downgrade '
                                        'the client, to compatible API versions.')

    @pytest.mark.skipif('WEB' not in TEST_OPTIONS, reason='Web client only')
    def test_check_service_compatibility_item_not_found(self, mocker):
        """
        Test check_service_compatibility_item raises warnings when expected.

        Test that a warning is raised if the service does not return a
        valid version item from its "/version" endpoint.
        """
        version_response = PlatformVersion()
        mocker.patch.object(
            self.client, 'get_version', return_value=version_response)
        with pytest.warns(UserWarning) as record:
            assert self.client._check_service_compatibility() is None
            assert 'Proceed with caution.' in str(record[0].message)

howso/client/tests/test_client.py
howsoai/howso-engine-py
from math import e

from howso.utilities import infer_feature_attributes
import numpy as np
import pandas as pd
import pytest

from . import NOMINAL_SUBSTITUTION_AVAILABLE


class TestInferFeatureAttributes:
    """Encapsulate InferFeatureAttributes tests."""

    def test_datetime_iso_sunny_path(self):
        """Test that infer_feature_attributes infers dates correctly."""
        df = pd.DataFrame(
            data=np.asarray(
                [
                    ['a', 'b', 'c', 'd'],
                    [
                        None, '2020-10-12T10:10:10',
                        '2020-12-12T12:12:12',
                        '1920-10-11T11:11:11'
                    ],
                ]
            ).transpose(), columns=['nom', 'datetime'])
        feature_attribs = infer_feature_attributes(df, tight_bounds=['nom', 'datetime'])

        assert feature_attribs['nom']['type'] == 'nominal'
        assert feature_attribs['datetime']['type'] == 'continuous'
        assert feature_attribs['datetime']['date_time_format'] == '%Y-%m-%dT%H:%M:%S'

        # values pre-epoch work and bounds are set to values in the dataset
        assert feature_attribs['datetime']['bounds']['min'] == '1920-10-11T11:11:11'
        assert feature_attribs['datetime']['bounds']['max'] == '2020-12-12T12:12:12'

    def test_not_datetime_column(self):
        """Test that infer_feature_attributes infers dates correctly."""
        df = pd.DataFrame(data=np.asarray([
            ["1.2", "3.4", "5.0"],
            ['2020-10-12T10:10:10', 'okay not really a date', 'that first value is a fluke']
        ]).transpose(), columns=['nom', 'not_actually_datetime'])
        feature_attribs = infer_feature_attributes(df)

        assert feature_attribs['nom']['type'] == 'nominal'
        assert feature_attribs['not_actually_datetime']['type'] == 'nominal'

    def test_datetime_custom_format(self):
        """Test that infer_feature_attributes infers dates correctly."""
        df = pd.DataFrame(data=np.asarray([
            ['1', '2', '3', ],
            ['10.10.2020', '12.12.2020', '11.11.1920']
        ]).transpose(), columns=['nom', 'datetime'])

        custom_format = {'datetime': ('%m.%d.%Y', 'en_US')}

        feature_attribs = infer_feature_attributes(df, datetime_feature_formats=custom_format)

        assert feature_attribs['nom']['type'] == 'nominal'
        assert feature_attribs['datetime']['type'] == 'continuous'
        assert feature_attribs['datetime']['date_time_format'] == '%m.%d.%Y'
        assert feature_attribs['datetime']['locale'] == 'en_US'

        # loose bounds select dates beyond what's in the dataset
        assert feature_attribs['datetime']['bounds']['min'] == '05.25.1856'
        assert feature_attribs['datetime']['bounds']['max'] == '08.08.2083'

    def test_datetime_warns_custom_format(self):
        """Test that infer_feature_attributes infers dates correctly."""
        df = pd.DataFrame(data=np.asarray([
            ['10.10.2020', '12.12.2020', '11.11.1920']
        ]).transpose(), columns=['datetime'])
        # convert column to contain datetime objects instead of strings
        df['datetime'] = pd.to_datetime(df['datetime'])

        custom_format = {'datetime': ('%m.%d.%Y', 'en_US')}

        expected_warning = (
            'Providing a datetime feature format for the feature "datetime" '
            'is not necessary because the data is already formatted as a '
            'datetime object. This custom format will be ignored.')

        with pytest.warns(Warning, match=expected_warning):
            feature_attribs = infer_feature_attributes(df, datetime_feature_formats=custom_format)

        assert feature_attribs['datetime']['type'] == 'continuous'
        assert 'locale' not in feature_attribs['datetime']
        assert feature_attribs['datetime']['date_time_format'] == (
            '%Y-%m-%dT%H:%M:%S')

    def test_datetime_iso_zoned_sunny_path(self):
        """Test that infer_feature_attributes infers dates correctly."""
        df = pd.DataFrame(data=np.asarray([
            ['2020-10-12T10:12:00Z', '2020-10-12T10:10:10Z', '2020-12-12T12:12:12Z', '1920-10-11T11:11:11Z'],
            [None, '2020-10-12T10:10:10+0000', '2020-12-12T12:12:12+0100', '1920-10-11T11:11:11-0300']
        ]).transpose(), columns=['datetime', 'datetimez'])
        feature_attribs = infer_feature_attributes(df, tight_bounds=['datetimez', 'datetime'])

        assert feature_attribs['datetime']['type'] == 'continuous'
        assert feature_attribs['datetime']['date_time_format'] == '%Y-%m-%dT%H:%M:%SZ'
        assert feature_attribs['datetimez']['type'] == 'continuous'
        assert feature_attribs['datetimez']['date_time_format'] == '%Y-%m-%dT%H:%M:%S%z'

        # values pre-epoch work and bounds are set to values in the dataset
        assert feature_attribs['datetime']['bounds']['min'] == '1920-10-11T11:11:11Z'
        assert feature_attribs['datetime']['bounds']['max'] == '2020-12-12T12:12:12Z'
        assert feature_attribs['datetimez']['bounds']['min'] == '1920-10-11T11:11:11-0300'
        assert feature_attribs['datetimez']['bounds']['max'] == '2020-12-12T12:12:12+0100'

    def test_determine_iso_format_warn(self):
        """Test that infer_feature_attributes infers dates correctly."""
        df = pd.DataFrame(data=np.asarray([
            ['a', 'b', 'c'],
            ['2010-10', '2012-01', '2011-01']
        ]).transpose(), columns=['nom', 'datetime'])

        # dates are a subset ISO format, show different warning
        with pytest.warns() as warn:
            feature_attribs = infer_feature_attributes(df)
            assert str(warn[0].message) == (
                "Feature datetime is a datetime but may not work properly if "
                "user doesn't specify the correct format.")
            assert feature_attribs['datetime']['type'] == 'continuous'
            assert feature_attribs['datetime']['date_time_format'] == '%Y-%m-%dT%H:%M:%S'

    @pytest.mark.skipif(not NOMINAL_SUBSTITUTION_AVAILABLE, reason="Nominal Substitution not supported")
    def test_infer_extended_nominal(self):
        """
        Test that `infer_feature_attributes` infers extended nominals correcly.

        ... to make sure warnings and other behavior is as per specification.
        """
        data = {
            "time": [1, 2, 3, 4],
            "gender": ["male", "male", "female", "female"],
            "ssn": ["1", "2", "3", "4"],
            "balance": [1.0, 2.0, 3.0, 4.0]
        }
        df = pd.DataFrame(data)

        # 1. Sanity check (non-timeseries)
        features = infer_feature_attributes(df)

        # No subtype is supposed to be set by default
        assert not features["gender"].get("subtype", None)
        assert not features["ssn"].get("subtype", None)

        # 2. Sanity check (timeseries)
        features = infer_feature_attributes(
            df, time_feature_name="time"
        )

        # No subtype is supposed to be set by default
        assert not features["gender"].get("subtype", None)
        assert not features["ssn"].get("subtype", None)
        assert features["time"]["time_series"]["delta_max"] == [e]
        assert features["time"]["time_series"]["delta_min"] == [1 / e]
        assert features["balance"]["time_series"]["rate_max"] == [e]
        assert features["balance"]["time_series"]["rate_min"] == [1 / e]

        # 3. Enable `attempt_infer_extended_nominals` parameter
        features = infer_feature_attributes(df, attempt_infer_extended_nominals=True)

        # 3a. Make sure it recognizes "gender" subtype
        assert features["gender"].get("subtype", None) == "gender"
        # 3b. Make sure it defaults to "int-id" subtype
        assert features["ssn"].get("subtype", None) == "int-id"

        # 4. Enable `attempt_infer_extended_nominals` parameter
        features = infer_feature_attributes(df, time_feature_name="time", attempt_infer_extended_nominals=True)

        # 4a. Make sure it recognizes "gender" subtype
        assert features["gender"].get("subtype", None) == "gender"
        # 4b. Make sure it defaults to "int-id" subtype
        assert features["ssn"].get("subtype", None) == "int-id"
        # 4c. Make sure "time" feature is continuous
        assert features["time"].get("type", None) == "continuous"
        # 4d. Make sure "time" has no subtypes
        assert not features["time"].get("subtype", None)

    def test_infer_time_series(self):
        """
        Test test_infer_time_series.

        Test the `infer_feature_attributes` for time series to make sure
        warnings and other behavior is as per specification.
        """
        data = {
            "ID": ["a", "a", "a", "a", "a", "a"],
            "time": [1, 2, 3, 4, 5, 6],
            "gender": ["male", "male", "male", "female", "female", "female"],
            "value": [11, 12, 15, 18, 23, 31],
            "balance": [1.0, 2.0, 3.0, 4.0, 4.9, 5.8],
            "bal_scaled": [10, 20, 30, 40, 49, 58]
        }
        df = pd.DataFrame(data)

        features = infer_feature_attributes(df, time_feature_name="time", id_feature_name="ID")

        # 1. Verify time series types
        assert features["time"]["time_series"]["type"] == 'delta'
        assert features["time"]["time_series"]["time_feature"] is True
        assert "time_series" not in features["ID"]

        # 1a. Make sure time series type is 'rate'
        assert features["gender"]["time_series"]["type"] == 'rate'
        assert features["value"]["time_series"]["type"] == 'rate'
        assert features["balance"]["time_series"]["type"] == 'rate'
        assert features["bal_scaled"]["time_series"]["type"] == 'rate'

        # 1b. Make sure rate min/max are stored, but not delta min/max
        assert "rate_max" in features["bal_scaled"]["time_series"]
        assert "rate_min" in features["bal_scaled"]["time_series"]
        assert "delta_max" not in features["bal_scaled"]["time_series"]
        assert "delta_min" not in features["bal_scaled"]["time_series"]

        features = infer_feature_attributes(
            df,
            time_feature_name="time",
            id_feature_name="ID",
            time_series_types_override={
                "balance": "delta",
                "bal_scaled": "delta"
            }
        )

        # 2. Verify custom-specified delta features are correctly set
        assert features["time"]["time_series"]["type"] == 'delta'
        assert features["gender"]["time_series"]["type"] == 'rate'
        assert features["value"]["time_series"]["type"] == 'rate'
        assert features["balance"]["time_series"]["type"] == 'delta'
        assert features["bal_scaled"]["time_series"]["type"] == 'delta'

        # 2a. Make sure for delta feature, delta min/max are stored instead
        assert "rate_max" not in features["bal_scaled"]["time_series"]
        assert "rate_min" not in features["bal_scaled"]["time_series"]
        assert "delta_max" in features["bal_scaled"]["time_series"]
        assert "delta_min" in features["bal_scaled"]["time_series"]

        features = infer_feature_attributes(
            df,
            time_feature_name="time",
            id_feature_name="ID",
            time_series_type_default='delta'
        )

        # 3. Verify all features set as 'delta'
        assert features["time"]["time_series"]["type"] == 'delta'
        assert features["gender"]["time_series"]["type"] == 'delta'
        assert features["value"]["time_series"]["type"] == 'delta'
        assert features["balance"]["time_series"]["type"] == 'delta'
        assert features["bal_scaled"]["time_series"]["type"] == 'delta'

        # 3a. Make sure delta min/max are stored instead of rate min/max
        assert "rate_max" not in features["value"]["time_series"]
        assert "rate_min" not in features["value"]["time_series"]
        assert "delta_max" in features["value"]["time_series"]
        assert "delta_min" in features["value"]["time_series"]

        features = infer_feature_attributes(
            df,
            time_feature_name="time",
            id_feature_name="ID",
            time_invariant_features=["gender", "balance"]
        )

        # 4. Verify time invariant features are correct, others use defaults
        assert "time_series" not in features["gender"]
        assert "time_series" not in features["balance"]
        assert features["value"]["time_series"]["type"] == 'rate'
        assert features["bal_scaled"]["time_series"]["type"] == 'rate'

        features = infer_feature_attributes(
            df,
            time_feature_name="time",
            id_feature_name="ID",
            time_series_types_override={
                "bal_scaled": "delta"
            },
            orders_of_derivatives={
                "value": 3,
                "bal_scaled": 2
            },
            derived_orders={"value": 3}
        )

        # 5. Verify high order rates and deltas are correctly computed
        assert features["value"]["time_series"]["order"] == 3
        assert features["value"]["time_series"]["rate_min"] == [1 / e, 0.0, -2 * e]
        assert features["value"]["time_series"]["rate_max"] == [8 * e, 3 * e, 2 * e]

        assert features["bal_scaled"]["time_series"]["order"] == 2
        assert features["bal_scaled"]["time_series"]["delta_min"] == [9 / e, -e]
        assert features["bal_scaled"]["time_series"]["delta_max"] == [10 * e, 0.0]

        # 6. Verify derived_orders is clamped down to 2
        assert features["value"]["time_series"]["derived_orders"] == 2

howso/client/tests/test_infer_feature_attributes.py
howsoai/howso-engine-py
import gc
import pickle
import uuid

from howso import engine
from howso.client.exceptions import HowsoNotUniqueError
from howso.client.pandas import HowsoPandasClient
from howso.direct import HowsoDirectClient
from howso.scikit import (
    CLASSIFICATION,
    HowsoClassifier,
    HowsoEstimator,
    HowsoRegressor,
)
from howso.utilities.testing import get_configurationless_test_client, get_test_options
import numpy as np
import pytest
from sklearn.base import clone
from sklearn.model_selection import cross_validate

TEST_OPTIONS = get_test_options()


@pytest.fixture(scope='function')
def classifier():
    """Creates a pre-populated Howso estimator."""
    # Let's learn how to classify the XOR operation.
    X = np.array([[0, 0], [1, 0], [0, 1], [1, 1],
                  [0, 0], [1, 0], [0, 1], [1, 1]])
    y = np.array([0, 1, 1, 0, 0, 1, 1, 0])
    howso = HowsoClassifier(
        client=get_configurationless_test_client(client_class=HowsoPandasClient))
    howso.fit(X, y)
    return howso


class TestHowso:
    """A test class for testing the estimators."""

    @classmethod
    def setup_class(cls):
        """Setup a test client to use for each test method."""
        cls.client = get_configurationless_test_client(client_class=HowsoPandasClient)

    def test_regressor(self):
        """Tests the HowsoRegressor from the external client."""
        X = np.array([1, 2, 3])
        y = np.array([4, 5, 6])
        howso = HowsoRegressor(client=self.client)
        howso.fit(X, y)
        print(howso.score(X, y))
        print(howso.predict(np.array([[4], [5], [6]])))
        # Ensure that the trainee is unnamed, so it will be deleted.
        howso.trainee_name = None

    def test_classifier(self):
        """Tests the HowsoClassifier from the external client."""
        # Use two instances of data indicating the xor operation to make sure
        # it is able to learn it.
        X = np.array([[0, 0], [1, 0], [0, 1], [1, 1],
                      [0, 0], [1, 0], [0, 1], [1, 1]])
        y = np.array([0, 1, 1, 0, 0, 1, 1, 0])
        howso = HowsoClassifier(client=self.client)
        howso.fit(X, y)
        assert howso.score(X, y) == 1.0
        assert howso.predict(np.array([[1, 1]])).sum() == 0
        # Ensure that the trainee is unnamed, so it will be deleted.
        howso.trainee_name = None

    def test_trainee_name_getter_setter(self, classifier):
        """Test that the trainee_name setter works as expected."""
        assert classifier.trainee_name is None
        new_name = str(uuid.uuid4())
        # Invoke and test setter
        classifier.trainee_name = new_name
        # Invoke and test getter
        assert classifier.trainee_name == new_name
        # Ensure that the trainee is unnamed, so it will be deleted.
        classifier.trainee_name = None

    @pytest.mark.parametrize('trainee_is_named', [True, False])
    def test_destructor(self, mocker, trainee_is_named):
        """
        Ensure that the destructor properly unloads or deletes the trainee.

        When the trainee is named, it should unload and NOT delete and
        when the trainee is unnamed, it should delete and NOT unload.

        Since this is going to explicitly destroy the classifier, it should
        create its own.
        """
        X = np.array([[0, 0], [1, 0], [0, 1], [1, 1]])
        y = np.array([0, 1, 1, 0])
        howso = HowsoClassifier(client=self.client)
        howso.fit(X, y)

        if trainee_is_named:
            howso.trainee_name = str(uuid.uuid4())
            _trainee_id = howso.trainee.id
        else:
            howso.trainee_name = None
            _trainee_id = None

        # Let's spy on the delete_trainee and release_trainee_resources methods
        delete_spy = mocker.spy(engine.Trainee, 'delete')
        unload_spy = mocker.spy(engine.Trainee, 'release_resources')

        # Capture their initial state (should be zero though)
        delete_count = delete_spy.call_count
        unload_count = unload_spy.call_count

        # Delete the estimator and ensure that garbage collection has occurred.
        del howso
        gc.collect()

        if trainee_is_named:
            assert delete_spy.call_count >= delete_count
            assert unload_spy.call_count > unload_count
        else:
            assert delete_spy.call_count > delete_count
            assert unload_spy.call_count >= unload_count

        # If the trainee was named, it would have been simply unloaded. Now
        # that testing has completed, ensure it is manually removed. For this
        # we'll just use `self.client``.
        if trainee_is_named and _trainee_id:
            self.client.delete_trainee(_trainee_id)

    def test_save(self, classifier):
        """
        Test that save works as intended.

        In particular, we should ensure that calling save() sets the trainee
        name, if necessary.
        """
        assert classifier.trainee_name is None
        # If this raises, obviously the test fails.
        classifier.save()
        assert classifier.trainee_name is not None

        # Testing has completed, ensure the name is reset so the estimator will
        # delete it as it is destructed.
        classifier.trainee_name = None

    def test_uniqueness_check(self, classifier):
        """Test Setting `trainee_name` to a used name fails as expected."""
        # Create a (degenerate) trainee with a known name outside of the
        # estimator (but using the same client for convenience).
        known_name = f'known-name-{uuid.uuid4()}'
        rogue_trainee = engine.Trainee(
            default_action_features=['a', 'b'],
            default_context_features=['c', 'd'],
            metadata={'fake-trainee': True},
            features={
                'a': {'type': 'nominal'},
                'b': {'type': 'nominal'},
                'c': {'type': 'nominal'},
                'd': {'type': 'nominal'}
            }
        )
        rogue_trainee.name = known_name
        # NOTE: This just uses the client embedded in the classifier here.
        #       This does not create a trainee for the classifier. And because
        #       of this, this test needs to explicitly delete this trainee
        #       when it is done with it.

        # Attempt to set name to existing name
        if not isinstance(classifier.client, HowsoDirectClient):
            with pytest.raises(HowsoNotUniqueError) as exc_info:
                rogue_trainee.name = known_name
            assert "Please use a unique name" in str(exc_info.value)
        else:
            # Ensure this doesn't raise
            rogue_trainee.name = known_name

        # Explicitly delete the rogue_trainee
        rogue_trainee.delete()
        # Reset to none so it won't be saved.
        rogue_trainee.name = None

    @pytest.mark.skipif('WIP' not in TEST_OPTIONS, reason='Local devs only')
    def test_pickle(self):
        """Test the pickling function with HowsoEstimator scikit Estimator."""
        # Test estimator; ensure it functions and has access to the client.
        X = np.array([[0, 0], [1, 0], [0, 1], [1, 1],
                      [0, 0], [1, 0], [0, 1], [1, 1]])
        y = np.array([0, 1, 1, 0, 0, 1, 1, 0])
        howso = HowsoEstimator(client=self.client, method=CLASSIFICATION)
        howso.fit(X, y)
        assert howso.score(X, y) == 1.0
        assert howso.predict(np.array([[1, 1]])).sum() == 0
        assert type(howso.client) is HowsoDirectClient

        # Pickle the Estimator; this should call howso.client.save which should
        # give the trainee a name save the trainee in the cloud.
        pickle_string = pickle.dumps(howso)
        print(f'Trainee name: {howso.trainee_name}')

        # Explicitly delete the estimator, which in turn will delete the
        # trainee and clear the variable that held the Estimator.
        howso.client.release_trainee_resources(howso.trainee_id)
        del howso

        # Load the estimator from the pickle to a new variable and conduct
        # the same assertion tests.
        howso2 = pickle.loads(pickle_string)
        assert howso2.score(X, y) == 1.0
        assert howso2.predict(np.array([[1, 1]])).sum() == 0
        assert type(howso2.client) is HowsoDirectClient

        # Delete the saved trainee to save resources.
        howso2.delete()

    def test_regressor_cv(self):
        """Test that HowsoRegressor works using cross-validation."""
        X = np.array([1, 2, 3])
        y = np.array([4, 5, 6])
        howso = HowsoRegressor(client=self.client)
        results = cross_validate(howso, X, y, cv=3)
        print(results["test_score"])

    def test_classifier_cv(self):
        """Test the HowsoClassifier works using cross-validation."""
        X = np.array([[0, 0], [1, 0], [0, 1], [1, 1]])
        y = np.array([0, 1, 1, 0])
        howso = HowsoClassifier(client=self.client)
        results = cross_validate(howso, X, y, cv=3)
        print(results["test_score"])

    def test_clone(self):
        """Tests the ability of HowsoClassifier to be cloned by sklearn."""
        howso = HowsoClassifier(client=self.client)
        new_howso = clone(howso)
        assert howso.get_params() == new_howso.get_params()

howso/client/tests/test_scikit.py
howsoai/howso-engine-py
"""The Python API for the Howso Direct Client."""

from .client import HowsoDirectClient
from .core import HowsoCore

__all__ = [
    "HowsoCore",
    "HowsoDirectClient"
]

howso/direct/__init__.py
howsoai/howso-engine-py
from datetime import datetime
from importlib import metadata
from pathlib import Path
import sysconfig
from typing import Union


def model_from_dict(klass, obj):
    """
    Create OpenAPI model instance from dict.

    Parameters
    ----------
    klass : Type
        The class to instantiate.
    obj : dict or None
        The dict containing the class attributes.

    Returns
    -------
    Any
        The class instance.
    """
    if obj is None:
        return None
    if not isinstance(obj, dict):
        raise ValueError('`obj` parameter is not a dict')
    if not hasattr(klass, 'attribute_map'):
        raise ValueError("`klass` is not an OpenAPI model")
    # Only use known attributes for class instantiation
    parameters = dict()
    for key in obj.keys():
        if key in klass.attribute_map:
            dtype = klass.openapi_types[key]
            if dtype == 'datetime':
                parameters[key] = datetime.fromisoformat(obj[key])
            else:
                parameters[key] = obj[key]
    return klass(**parameters)


def get_file_in_distribution(file_path) -> Union[Path, None]:
    """
    Locate the LICENSE.txt file in the distribution of this package.

    Parameters
    ----------
    file_path : str
        The name/path of the desired file relative to the package distribution.

    Returns
    -------
    Path or None
        The path to the requested file or None, if not found.
    """
    purelib_path = sysconfig.get_path('purelib')
    dist = metadata.distribution('howso-engine')
    for fp in dist.files:
        if fp.name == file_path:
            return Path(purelib_path, fp)

howso/direct/_utilities.py
howsoai/howso-engine-py
from concurrent.futures import Future, ThreadPoolExecutor
from contextlib import contextmanager
from copy import deepcopy
from datetime import datetime
from http import HTTPStatus
import json
import logging
import multiprocessing
import operator
import os
from pathlib import Path
import platform
from typing import (
    Any,
    Callable,
    Dict,
    Iterable,
    List,
    Literal,
    Optional,
    Sequence,
    Sized,
    Tuple,
    Union,
)
import uuid
import warnings

import certifi
from howso import utilities as util
from howso.client import __version__ as local_version, AbstractHowsoClient
from howso.client.cache import TraineeCache
from howso.client.exceptions import HowsoError
import howso.openapi.models as client_models
from howso.openapi.models import (
    Session,
    Trainee,
)
from howso.utilities import (
    internals,
    num_list_dimensions,
    ProgressTimer,
    replace_doublemax_with_infinity,
    serialize_cases,
    validate_case_indices,
    validate_list_shape,
)
from howso.utilities.feature_attributes.base import (
    SingleTableFeatureAttributes,
    MultiTableFeatureAttributes,
)
import numpy as np
from packaging.version import parse as parse_version
from pandas import DataFrame
from typing_extensions import Never
import urllib3
from urllib3.util import Retry, Timeout

from ._utilities import model_from_dict
from .core import HowsoCore

# Configure howso base logger
logger = logging.getLogger('howso.direct')

_VERSION_CHECKED = False
DT_FORMAT_KEY = 'date_time_format'
HYPERPARAMETER_KEY = "hyperparameter_map"
VERSION_CHECK_HOST = "https://version-check.howso.com"

# Cache of trainee information shared across client instances
_trainee_cache = TraineeCache()

# Cache of core entities shared across client instances
_core_cache = dict()


@contextmanager
def squelch_logs(log_level: int):
    """A context manager to temporarily disable logs."""
    _old_level = logging.root.manager.disable
    logging.disable(log_level)
    try:
        yield
    finally:
        logging.disable(_old_level)


class HowsoDirectClient(AbstractHowsoClient):
    """
    The direct Howso client.

    A client which provides access to the Howso core endpoints
    via a direct interface using dynamic libraries.

    Parameters
    ----------
    howso_core : howso.direct.HowsoCore, optional
        A specified howso core direct interface object.

        If None, an interface will be generated using the provided handle.
    debug : bool, default False
        Set debug output.
    handle : str, optional
        The howso core entity handle to use.

        If None, :attr:`HowsoDirectClient.DEFAULT_HANDLE` will be used.
    verbose : bool, default False
        Set verbose output.
    """

    #: The default Howso core entity handle.
    DEFAULT_HANDLE = "howso"

    #: The characters which are disallowed from being a part of a Trainee name or ID.
    BAD_TRAINEE_NAME_CHARS = {'..', '\\', '/', ':'}

    #: The supported values of precision for methods that accept it
    SUPPORTED_PRECISION_VALUES = ["exact", "similar"]
    INCORRECT_PRECISION_VALUE_WARNING = (
        "Supported values for 'precision' are \"exact\" and \"similar\". The "
        "operation will be completed as if the value of 'precision' is "
        "\"exact\"."
    )

    def __init__(
        self,
        howso_core: Optional[HowsoCore] = None,
        *,
        debug: bool = False,
        handle: Optional[str] = None,
        verbose: bool = False,
        version_check: bool = True,
        **kwargs
    ):
        global _VERSION_CHECKED

        # Set the 'howso' logger level to debug
        if debug:
            # Don't alter if level already below debug
            if logger.level > logging.DEBUG or logger.level == 0:
                logger.setLevel(logging.DEBUG)

        with ThreadPoolExecutor(max_workers=1) as executor:
            if version_check and not _VERSION_CHECKED:
                _VERSION_CHECKED = True
                self.version_check_task = executor.submit(self.check_version)
                self.version_check_task.add_done_callback(self.report_version)

        super().__init__()
        handle = str(handle or self.DEFAULT_HANDLE)

        # Show deprecation warnings to the user.
        warnings.filterwarnings("default", category=DeprecationWarning)

        self.verbose = verbose
        self.debug = debug

        if howso_core is None:
            if handle not in _core_cache:
                _core_cache[handle] = HowsoCore(
                    self.get_unique_handle(handle),
                    **kwargs
                )
            self.howso = _core_cache[handle]
        elif isinstance(howso_core, HowsoCore):
            self.howso = howso_core
        else:
            raise ValueError("The client parameter howso_core must be "
                             "an instance of HowsoCore")
        self.batch_scaler_class = internals.BatchScalingManager
        self._active_session = None
        self._react_generative_batch_threshold = 1
        self._react_discriminative_batch_threshold = 10
        self.begin_session()

    def check_version(self) -> Union[str, None]:
        """Check if there is a more recent version."""
        http = urllib3.PoolManager(cert_reqs='CERT_REQUIRED',
                                   ca_certs=certifi.where(),
                                   retries=Retry(total=1),
                                   timeout=Timeout(total=3),
                                   maxsize=10)
        url = f"{VERSION_CHECK_HOST}/v1/howso-engine?version={local_version}"
        with squelch_logs(logging.WARNING + 1):
            response = http.request(method="GET", url=url)
        if HTTPStatus.OK <= response.status < HTTPStatus.MULTIPLE_CHOICES:
            payload = json.loads(response.data.decode('utf-8'))
            return payload.get('version')
        raise AssertionError("Not OK response.")

    def report_version(self, task: Future):
        """Report to end-user that there is a newer version available."""
        try:
            latest_version = task.result()
        except Exception:
            pass
        else:
            if latest_version and latest_version != local_version:
                if parse_version(latest_version) > parse_version(local_version):
                    logger.warning(
                        f"Version {latest_version} of Howso Engine™ is "
                        f"available. You are using version {local_version}.")
                elif parse_version(latest_version) < parse_version(local_version):
                    logger.debug(
                        f"Version {latest_version} of Howso Engine™ is "
                        f"available. You are using version {local_version}. "
                        f"This is a pre-release version.")

    @property
    def active_session(self) -> Session:
        """
        Return the active session.

        Returns
        -------
        howso.openapi.models.Session
            The active session instance.
        """
        return deepcopy(self._active_session)

    @property
    def trainee_cache(self) -> TraineeCache:
        """
        Return the trainee cache.

        Returns
        -------
        howso.client.cache.TraineeCache
            The trainee cache.
        """
        return _trainee_cache

    @staticmethod
    def get_unique_handle(handle: str) -> str:
        """
        Append a unique 6 byte hex to the input handle.

        Parameters
        ----------
        handle : str
            String to which a unique 6 byte hex string will appended.

        Returns
        -------
        str
            A unique alphanumeric handle consisting of the input string and
            a unique 6 byte hex string.
        """
        return f"{handle}-{HowsoCore.random_handle()}"

    def get_entities(self) -> List[str]:
        """
        Return a list of loaded core entities.

        Returns
        -------
        iterable of str
            The list of loaded entity names.
        """
        return self.howso.get_entities()

    def get_version(self) -> client_models.ApiVersion:
        """
        Return the Howso version.

        Returns
        -------
        howso.openapi.models.ApiVersion
           A version response that contains the version data for the current
           instance of Howso.
        """
        from howso.client import __version__ as client_version
        from howso.openapi import __api_version__ as api_version

        return client_models.ApiVersion(api=api_version,
                                        client=client_version)

    def _output_version_in_trace(self, trainee: str):
        """
        Instruct Howso core to retrieve the version of the Trainee.

        If debugging is enabled, this version will appear in the trace file.

        Parameters
        ----------
        trainee : str
            The ID of the Trainee that should retrieve the Howso version.
        """
        from howso.client import __version__
        amlg_version = self.howso.amlg.get_version_string()
        self.howso.version()
        trace_version = f"client: {__version__}  amalgam: {amlg_version}"

        # don't need to return the output, make the call to core in order for
        # the stack version to show up in the trace file.
        self.howso.get_trainee_version(trainee, trace_version)

    def check_name_valid_for_save(
        self,
        file_path: Union[Path, str],
        clobber: bool = False,
    ) -> Tuple[bool, str]:
        """
        Ensure that the given filename is a valid name for the host OS.

        Parameters
        ----------
        file_path : Path or str
            The full path of the desired Trainee.
        clobber : bool, default False
            If True, checks will pass if the file is writable even if it
            already exists.

        Returns
        -------
        bool
            Return True if the file has a valid filename, is a filepath (not a
            directory path), that the process (user) has sufficient permissions
            and, if `clobber` is False, also that the file does not already
            exist (optional check).
        str
            The reason. If the return is True, this will be 'OK'.

        """
        try:
            # Check for invalid chars in the whole path.
            if any((c for c in ["\0"] if c in str(file_path))):
                return False, 'Bad symbols'

            # Ensure file_path is a Path.
            if not isinstance(file_path, Path):
                file_path = Path(file_path)

            # Ensure that it resolves to an absolute path
            if not file_path.resolve(strict=False).is_absolute():
                return False, 'Not an absolute path'

            # Ensure that the parent directory exists and appears writable for
            # the /effective/ user on non-Windows.
            if platform.system().lower() != "windows":
                path = file_path.parent
                if not clobber and (
                    not path.exists() or
                    not os.access(path, os.W_OK, effective_ids=True,
                                  follow_symlinks=True)
                ):
                    return False, 'Cannot write to this path'

        except Exception as e:  # noqa: Deliberately broad
            return False, f'Exception {e} while checking file'
        else:
            return True, 'OK'

    def create_trainee(  # noqa: C901
        self,
        trainee: Trainee,
        *,
        library_type: Optional[Literal["st", "mt"]] = None,
        max_wait_time: Optional[Union[int, float]] = None,
        overwrite_trainee: bool = False,
        resources: Optional[Union[client_models.TraineeResources, Dict]] = None,
    ) -> Trainee:
        """
        Create a Trainee on the Howso service.

        A Trainee can be thought of as "model" in traditional ML sense.

        Parameters
        ----------
        trainee : Trainee
            A `Trainee` object defining the Trainee.
        library_type : {"st", "mt"}, optional
            (Not implemented) The library type of the Trainee.
        max_wait_time : int or float, default 30
            (Not implemented) The number of seconds to wait for a trainee to
            be created before aborting gracefully.
        overwrite_trainee : bool, default False
            If True, and if a trainee with id `trainee.id`
            already exists, the given trainee will delete the old trainee and
            create the new trainee.
        resources : howso.openapi.models.TraineeResources or dict, optional
            (Not implemented) Customize the resources provisioned for the
            Trainee instance.

        Returns
        -------
        Trainee
            The `Trainee` object that was created.
        """
        if not trainee.id:
            # Default id to trainee name, or new uuid if no name
            trainee.id = trainee.name or str(uuid.uuid4())

        trainee_id = trainee.id

        # Check that the trainee.id is usable for saving later.
        if trainee.name:
            for sequence in self.BAD_TRAINEE_NAME_CHARS:
                if sequence in trainee.name:
                    success = False
                    reason = f'"{sequence}" is not permitted in trainee names'
                    break
            else:
                success, reason = True, 'OK'
            proposed_path: Path = self.howso.default_save_path.joinpath(trainee.name)
            if success:
                success, reason = self.check_name_valid_for_save(
                    proposed_path, clobber=overwrite_trainee)
            if not success:
                raise HowsoError(
                    f'Trainee file name "{proposed_path}" is not valid for '
                    f'saving (reason: {reason}).')

        # If overwriting the trainee, attempt to delete it first.
        if overwrite_trainee:
            try:
                util.dprint(self.verbose, f"Deleting existing {trainee_id} "
                                          "trainee before creating.")
                self.howso.delete(trainee_id)
            except Exception:  # noqa: Deliberately broad
                util.dprint(self.verbose, f"Failed to delete {trainee_id} "
                                          "trainee. Continuing.")
        elif trainee_id in self.trainee_cache:
            raise HowsoError(
                f'A trainee already exists using the name "{trainee_id}"')

        trainee = internals.preprocess_trainee(trainee)
        if self.verbose:
            print('Creating trainee')
        result = self.howso.create_trainee(trainee_id)
        if not result:
            raise ValueError(
                f"Could not create the trainee with name {trainee_id}. "
                f"Possible causes - Howso couldn't find core "
                f"binaries/camls or {trainee_id} trainee already exists.")

        metadata = {
            'name': trainee.name,
            'default_context_features': trainee.default_context_features,
            'default_action_features': trainee.default_action_features,
            'metadata': trainee.metadata,
            'persistence': trainee.persistence,
        }
        self.howso.set_metadata(trainee_id, metadata)
        self.howso.set_feature_attributes(trainee_id, trainee.features)
        trainee.features = self.howso.get_feature_attributes(trainee_id)

        self._output_version_in_trace(trainee_id)

        new_trainee = internals.postprocess_trainee(trainee)
        self.trainee_cache.set(new_trainee, entity_id=self.howso.handle)
        return new_trainee

    def update_trainee(self, trainee: Trainee):
        """
        Update an existing Trainee in the Howso service.

        Parameters
        ----------
        trainee : Trainee
            A `Trainee` object defining the Trainee.

        Returns
        -------
        Trainee
            The `Trainee` object that was updated.
        """
        if trainee.id:
            trainee_id = trainee.id
        else:
            trainee_id = trainee.id = trainee.name

        if not trainee_id:
            raise ValueError("A trainee id is required.")

        self._auto_resolve_trainee(trainee_id)
        if self.verbose:
            print(f'Updating trainee with id: {trainee.id}')

        trainee = internals.preprocess_trainee(trainee)
        metadata = {
            'name': trainee.name,
            'default_context_features': trainee.default_context_features,
            'default_action_features': trainee.default_action_features,
            'metadata': trainee.metadata,
            'persistence': trainee.persistence,
        }
        self.howso.set_metadata(trainee_id, metadata)
        self.howso.set_feature_attributes(trainee_id, trainee.features)
        trainee.features = self.howso.get_feature_attributes(trainee_id)

        updated_trainee = internals.postprocess_trainee(trainee)
        self.trainee_cache.set(updated_trainee)
        return updated_trainee

    def export_trainee(
        self,
        trainee_id: str,
        path_to_trainee: Optional[Union[Path, str]] = None,
        decode_cases: bool = False,
        separate_files: bool = False
    ):
        """
        Export a saved Trainee's data to json files for migration.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee.
        path_to_trainee : Path or str, optional
            The path to where the saved trainee file is located.
        decoded_cases : bool, default False.
            Whether to export decoded cases.
        separate_files : bool, default False
            Whether to load each case from its individual file.
        """
        if self.verbose:
            print(f'Export trainee with id: {trainee_id}')

        self.howso.export_trainee(trainee_id, path_to_trainee, decode_cases,
                                  separate_files)

    def upgrade_trainee(
        self,
        trainee_id: str,
        path_to_trainee: Optional[Union[Path, str]] = None,
        separate_files: bool = False
    ):
        """
        Upgrade a saved Trainee to current version.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee.
        path_to_trainee : Path or str, optional
            The path to where the saved Trainee file is located.
        separate_files : bool, default False
            Whether to load each case from its individual file.
        """
        if self.verbose:
            print(f'Upgrade trainee with id: {trainee_id}')

        self.howso.upgrade_trainee(trainee_id, path_to_trainee, separate_files)

    def get_trainee(self, trainee_id: str):
        """
        Gets a trainee loaded in the Howso service.

        Parameters
        ----------
        trainee_id : str
            The id of the trainee.

        Returns
        -------
        Trainee
            A `Trainee` object representing the Trainee.
        """
        if self.verbose:
            print(f'Getting trainee with id: {trainee_id}')
        self._auto_resolve_trainee(trainee_id)
        return self._get_trainee_from_core(trainee_id)

    def get_trainee_information(self, trainee_id: str
                                ) -> client_models.TraineeInformation:
        """
        Get information about the trainee.

        Including trainee version and configuration parameters.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee.

        Returns
        -------
        howso.openapi.models.TraineeInformation
            The Trainee information.
        """
        self._auto_resolve_trainee(trainee_id)
        trainee_version = self.howso.get_trainee_version(trainee_id)
        core_version = self.howso.version()
        amlg_version = self.howso.amlg.get_version_string().decode()
        library_type = 'st'
        if self.howso.amlg.library_postfix:
            library_type = self.howso.amlg.library_postfix[1:]

        version = client_models.TraineeVersion(core=core_version,
                                               amalgam=amlg_version,
                                               trainee=trainee_version)

        return client_models.TraineeInformation(library_type=library_type,
                                                version=version)

    def get_trainee_metrics(self, trainee_id: str) -> Never:
        """
        This endpoint is not implemented for the direct Howso client.

        Raises
        ------
        NotImplementedError
            This endpoint is not implemented for the direct Howso client.
        """
        raise NotImplementedError("`get_trainee_metrics` not implemented")

    def get_trainees(self, search_terms: Optional[str] = None):
        """
        Return a list of all trainees.

        Parameters
        ----------
        search_terms : str
            Keywords to filter trainee list by.

        Returns
        -------
        list of howso.openapi.models.TraineeIdentity
            A list of the trainee identities.
        """
        trainees = list()
        filter_terms = []
        if search_terms:
            filter_terms = search_terms.replace(',', ' ').split(' ')

        def is_match(name):
            # Check if name matches filter terms
            if filter_terms:
                return any((
                    str(term).lower() in name.lower()
                    for term in filter_terms
                ))
            return True

        # Collect in memory trainees
        for _, instance in self.trainee_cache.trainees():
            if is_match(instance.name):
                trainees.append(
                    client_models.TraineeIdentity(
                        name=instance.name, id=instance.id)
                )

        # Collect persisted trainees
        files = os.listdir(self.howso.default_save_path)
        for f in files:
            if not f.endswith(self.howso.ext):
                continue
            # remove the extension from the file name
            trainee_name = f[:f.rindex('.')]
            if (
                trainee_name not in self.trainee_cache and
                is_match(trainee_name)
            ):
                trainees.append(
                    client_models.TraineeIdentity(name=trainee_name,
                                                  id=trainee_name))

        return trainees

    def delete_trainee(
        self,
        trainee_id: Optional[str] = None,
        file_path: Optional[Union[Path, str]] = None
    ):
        """
        This deletes the Trainee, which includes all cases, model metadata,
        session data, persisted files, etc.

        Parameters
        ----------
        trainee_id : str, optional
            The ID of the Trainee. If full filepath with is provided, `trainee_id` will only be used
            to delete from core.

        file_path : Path or str, optional
            The path of the file to load the Trainee from. Used for deleting trainees from disk.

            The file path must end with a filename, but file path can be either an absolute path, a
            relative path or just the file name.

            If `trainee_id` is not provided, in addition to deleting from disk, will attempt to
            delete a Trainee from memory assuming the Trainee has the same name as the filename.

            If `file_path` is a relative path the absolute path will be computed
            appending the `file_path` to the CWD.

            If `file_path` is an absolute path, this is the absolute path that
            will be used.

            If `file_path` is just a filename, then the absolute path will be computed
            appending the filename to the CWD.
        """

        if file_path:
            if not isinstance(file_path, Path):
                file_path = Path(file_path)
            file_path = file_path.expanduser().resolve()

        if trainee_id:
            for sub in self.BAD_TRAINEE_NAME_CHARS:
                if sub in trainee_id:
                    raise ValueError(
                        f'"{sub}" is not permitted in trainee names for deletion.')
        else:
            trainee_id = file_path.stem

        # Unload the trainee from core
        self.howso.delete(trainee_id)
        self.trainee_cache.discard(trainee_id)

        if self.verbose:
            print(f'Deleting trainee with id {trainee_id}')

        if file_path:
            # Either full filepath or filename
            if file_path.suffix:
                save_path = f"{file_path.parents[0]}/"
                trainee_id = file_path.stem
            # Just Directory
            else:
                raise ValueError("Filepath must end with a '.caml' filename.")
            if not file_path.is_absolute():
                file_path = self.client.howso.default_save_path.joinpath(file_path)

        else:
            save_path = self.howso.default_save_path

        trainee_path = Path(save_path, f'{trainee_id}{self.howso.ext}')
        trainee_ver_path = Path(save_path, f'{trainee_id}Version.txt')

        # Delete Trainee
        if trainee_path.exists():
            trainee_path.unlink()

        # Do the same for the version file.
        if trainee_ver_path.exists():
            trainee_ver_path.unlink()

    def copy_trainee(
        self,
        trainee_id: str,
        new_trainee_name: Optional[str] = None,
        new_trainee_id: Optional[str] = None,
        *,
        library_type: Optional[Literal["st", "mt"]] = None,
        resources: Optional[Union[client_models.TraineeResources, Dict]] = None,
    ) -> Trainee:
        """
        Copies a trainee to a new trainee id in the Howso service.

        Parameters
        ----------
        trainee_id : str
            The trainee id of the trainee to be copied.
        new_trainee_name: str, optional
            The name of the new Trainee.
        new_trainee_id: str, optional
            The id of the new Trainee.

            If not provided, the id will be set to new_trainee_name
            (if provided), otherwise a new uuid4.
        library_type : str, optional
            (Not Implemented) The library type of the Trainee. If not specified,
            the new trainee will inherit the value from the original.
        resources : howso.openapi.models.TraineeResources or dict, optional
            (Not Implemented) Customize the resources provisioned for the
            Trainee instance. If not specified, the new trainee will inherit
            the value from the original.

        Returns
        -------
        Trainee
            The `Trainee` object that was created.

        Raises
        ------
        ValueError
            If the Trainee could not be copied.
        """
        self._auto_resolve_trainee(trainee_id)
        original_trainee = self.trainee_cache.get(trainee_id)

        new_trainee_id = new_trainee_id or new_trainee_name or str(uuid.uuid4())
        output = self.howso.copy(trainee_id, new_trainee_id)

        if self.verbose:
            print(f'Copying trainee {trainee_id} to {new_trainee_id}')

        # copy in core succeeded
        if output and output.get('name') == new_trainee_id:
            # Create the copy trainee
            new_trainee = deepcopy(original_trainee)
            new_trainee.name = new_trainee_name
            new_trainee.id = new_trainee_id
            metadata = {
                'name': new_trainee.name,
                'default_context_features': new_trainee.default_context_features,
                'default_action_features': new_trainee.default_action_features,
                'metadata': new_trainee.metadata,
                'persistence': new_trainee.persistence,
            }
            self.howso.set_metadata(new_trainee_id, metadata)
            self.trainee_cache.set(new_trainee, entity_id=self.howso.handle)

            return new_trainee
        else:
            raise ValueError(
                f"Could not copy the trainee with name {trainee_id}. Possible "
                f"causes - howso couldn't find core binaries/camls or "
                f"{new_trainee_name} trainee already exists."
            )

    def load_trainee(self, trainee_id: str):
        """
        Load a Trainee that was persisted on the Howso service.

        .. deprecated:: 1.0.0
            Use :meth:`HowsoDirectClient.acquire_trainee_resources` instead.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee load.
        """
        warnings.warn(
            'The method `load_trainee()` is deprecated and will be removed in '
            'a future release. Please use `acquire_trainee_resources()` '
            'instead.', DeprecationWarning)
        self.acquire_trainee_resources(trainee_id)

    def unload_trainee(self, trainee_id: str):
        """
        Unload a Trainee from the Howso service.

        .. deprecated:: 1.0.0
            Use :meth:`HowsoDirectClient.release_trainee_resources` instead.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee unload.
        """
        warnings.warn(
            'The method `unload_trainee()` is deprecated and will be removed '
            'in a future release. Please use `release_trainee_resources()` '
            'instead.', DeprecationWarning)
        self.release_trainee_resources(trainee_id)

    def acquire_trainee_resources(
        self,
        trainee_id: str,
        *,
        max_wait_time: Optional[Union[int, float]] = None
    ):
        """
        Acquire resources for a trainee in the Howso service.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to acquire resources for.
        max_wait_time : int or float, optional
            (Not implemented) The number of seconds to wait to acquire trainee
            resources before aborting gracefully.

        Raises
        ------
        HowsoError
            If no Trainee with the requested ID can be found.
        """
        if trainee_id is None:
            raise HowsoError("A trainee id is required.")
        if self.verbose:
            print(f'Acquiring resources for trainee with id: {trainee_id}')

        if trainee_id in self.trainee_cache:
            # Trainee is already loaded
            cache_item = self.trainee_cache.get_item(trainee_id)
            if cache_item.get('entity_id') != self.howso.handle:
                raise HowsoError(
                    "Unable to acquire trainee resources for the trainee "
                    f"'{trainee_id}'. Trainee is already loaded in another "
                    "core entity. Use the HowsoClient instance with the "
                    f"entity handle '{self.howso.handle}' instead or release it "
                    "via the other client first."
                )
            return

        ret = self.howso.load(trainee_id)

        if ret is None:
            raise HowsoError(f"Trainee '{trainee_id}' not found.")

        trainee = self._get_trainee_from_core(trainee_id)
        self.trainee_cache.set(trainee, entity_id=self.howso.handle)

    def _get_trainee_from_core(self, trainee_id: str) -> Trainee:
        """
        Retrieve the core representation of a Trainee object.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to retrieve.

        Returns
        -------
        Trainee
            The requested Trainee.

        Raises
        ------
        HowsoError
            If no Trainee with the requested ID can be found.
        """
        metadata = self.howso.get_metadata(trainee_id)
        if metadata is None:
            raise HowsoError(f"Trainee '{trainee_id}' not found.")

        action_features = metadata.get("default_action_features", list())
        context_features = metadata.get("default_context_features", list())
        persistence = metadata.get('persistence', 'allow')
        trainee_meta = metadata.get('metadata')
        trainee_name = metadata.get('name')

        features = self.howso.get_feature_attributes(trainee_id)
        loaded_trainee = Trainee(
            name=trainee_name,
            id=trainee_id,
            features=features,
            persistence=persistence,
            metadata=trainee_meta,
            default_action_features=action_features,
            default_context_features=context_features
        )
        return internals.postprocess_trainee(loaded_trainee)

    def release_trainee_resources(self, trainee_id: str):
        """
        Release a trainee's resources from the Howso service.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to release resources for.

        Raises
        ------
        HowsoError
            If the requested Trainee has a persistence of "never".
        """
        if self.verbose:
            print(f'Releasing resources for trainee with id: {trainee_id}')
        try:
            cache_item = self.trainee_cache.get_item(trainee_id)
            trainee = cache_item['trainee']
            if cache_item.get('entity_id', self.howso.handle) == self.howso.handle:
                if trainee.persistence in ['allow', 'always']:
                    # Persist on unload
                    self.howso.persist(trainee_id)
                elif trainee.persistence == "never":
                    raise HowsoError(
                        "Trainees set to never persist may not have their "
                        "resources released. Delete the Trainee instead.")
                self.trainee_cache.discard(trainee_id)
        except KeyError:
            # Trainee not cached, ignore
            pass
        self.howso.delete(trainee_id)

    def persist_trainee(self, trainee_id: str):
        """
        Persists a Trainee in the Howso service storage.

        After persisting, the Trainee resources can be
        :func:`released <client.HowsoClient.release_trainee_resources>`.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to persist.

        Raises
        ------
        AssertionError
            If the requested Trainee's persistence is set to "never".
        """
        if self.verbose:
            print(f'Saving trainee with id: {trainee_id}')

        if trainee_id in self.trainee_cache:
            trainee = self.trainee_cache.get(trainee_id)
            if trainee.persistence == 'never':
                raise AssertionError(
                    "Trainee is set to never persist. Update the trainee "
                    "persistence option to enable persistence.")
            # Enable auto persistence
            trainee.persistence = 'always'

        self.howso.persist(trainee_id)

    def _auto_resolve_trainee(self, trainee_id: str):
        """
        Resolve a Trainee and acquire its resources.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to persist.

        Raises
        ------
        HowsoError
            If the requested Trainee is currently loaded by another core entity.
        """
        if trainee_id not in self.trainee_cache:
            self.acquire_trainee_resources(trainee_id)
        else:
            entity_id = self.trainee_cache.get_item(trainee_id).get('entity_id')
            if entity_id != self.howso.handle:
                raise HowsoError(
                    f"Attempted to access the trainee '{trainee_id}' via a "
                    "client using a different core entity than the entity "
                    "where the trainee is currently loaded. Use the "
                    "HowsoClient instance with the core entity handle "
                    f"'{self.howso.handle}' instead to access this trainee or "
                    "release it via the other client first.")

    def _auto_persist_trainee(self, trainee_id: str):
        """
        Automatically persists the Trainee if it has persistence set to True.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to persist.
        """
        try:
            trainee = self.trainee_cache.get(trainee_id)
            if trainee.persistence == 'always':
                self.howso.persist(trainee_id)
        except KeyError:
            # Trainee not cached, ignore
            pass

    def remove_series_store(self, trainee_id: str, series: Optional[str] = None):
        """
        Clear any stored series from the Trainee.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to remove the series store from.
        series : str, optional
            The ID of the series to clear.

            If None, the Trainee's entire series store will be cleared.
        """
        self._auto_resolve_trainee(trainee_id)
        if self.verbose:
            print('Removing stored series from trainee with id: '
                  f'{trainee_id} and series with id: {series}')
        self.howso.remove_series_store(trainee_id, series)

    def set_substitute_feature_values(
        self, trainee_id: str, substitution_value_map: Dict[str, Dict]
    ):
        """
        Set a Trainee's substitution map for use in extended nominal generation.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to set substitute feature values for.
        substitution_value_map : dict
            A dictionary of feature name to a dictionary of feature value to
            substitute feature value.
        """
        self._auto_resolve_trainee(trainee_id)
        if self.verbose:
            print('Setting substitute feature values for trainee with '
                  f'id: {trainee_id}')
        self.howso.set_substitute_feature_values(trainee_id, substitution_value_map)
        self._auto_persist_trainee(trainee_id)

    def get_substitute_feature_values(
        self, trainee_id: str, clear_on_get: bool = True
    ) -> Dict[str, Dict]:
        """
        Gets a substitution map for use in extended nominal generation.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to get the substitution feature values from.

        clear_on_get : bool, default True
            Clears the substitution values map in the Trainee upon retrieving
            them. This is done if it is desired to prevent the substitution map
            from being persisted. If set to False the model will not be cleared
            which preserves substitution mappings if the model is saved;
            representing a potential privacy leak should the substitution map
            be made public.

        Returns
        -------
        dict of dict
            A dictionary of feature name to a dictionary of feature value to
            substitute feature value.
        """
        self._auto_resolve_trainee(trainee_id)
        if self.verbose:
            print(f'Getting substitute feature values from trainee with '
                  f'id: {trainee_id}')
        ret = self.howso.get_substitute_feature_values(trainee_id)
        if clear_on_get:
            self.set_substitute_feature_values(trainee_id, {})
        if ret is None:
            return dict()
        return ret

    def set_random_seed(self, trainee_id: str, seed: Union[int, float, str]):
        """
        Sets the random seed for the trainee.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to set the random seed for.
        seed: int or float or str
            The random seed.
            Ex: ``7998``, ``"bobtherandomseed"``
        """
        self._auto_resolve_trainee(trainee_id)
        if self.verbose:
            print(f'Setting random seed for trainee with id: {trainee_id}')
        self.howso.set_random_seed(trainee_id, seed)
        self._auto_persist_trainee(trainee_id)

    def train(  # noqa: C901
        self,
        trainee_id: str,
        cases: Union[List[List[object]], DataFrame],
        features: Optional[Iterable[str]] = None,
        *,
        ablatement_params: Optional[Dict[str, List[object]]] = None,
        accumulate_weight_feature: Optional[str] = None,
        batch_size: Optional[int] = None,
        derived_features: Optional[Iterable[str]] = None,
        input_is_substituted: bool = False,
        progress_callback: Optional[Callable] = None,
        series: Optional[str] = None,
        train_weights_only: bool = False,
        validate: bool = True,
    ):
        """
        Train one or more cases into a trainee (model).

        Parameters
        ----------
        trainee_id : str
            The ID of the target Trainee.
        cases : list of list of object or pandas.DataFrame
            One or more cases to train into the model.
        features : iterable of str, optional
            An iterable of feature names.
            This parameter should be provided in the following scenarios:

                a. When cases are not in the format of a DataFrame, or
                   the DataFrame does not define named columns.
                b. You want to train only a subset of columns defined in your
                   cases DataFrame.
                c. You want to re-order the columns that are trained.

        ablatement_params : dict of str to list of object, optional
            Where keys are a feature name and values are threshold_type where
            threshold_type is one of:

                - ['exact']: Don't train if prediction matches exactly
                - ['tolerance', MIN, MAX]: Don't train if ``prediction
                  >= (case value - MIN) & prediction <= (case value + MAX)``
                - ['relative', PERCENT]: Don't train if
                  ``abs(prediction - case value) / prediction <= PERCENT``
                - ['residual']: Don't train if
                  ``abs(prediction - case value) <= feature residual``

            >>> {'species': ['exact'], 'sepal_length': ['tolerance', 0.1, 0.25]}

        accumulate_weight_feature : str, optional
            Name of feature into which to accumulate neighbors'
            influences as weight for ablated cases. If unspecified, will not
            accumulate weights.
        batch_size: int, optional
            Define the number of cases to train at once. If left unspecified,
            the batch size will be determined automatically.
        derived_features: iterable of str, optional
            List of feature names for which values should be derived
            in the specified order. If this list is not provided, features with
            the 'auto_derive_on_train' feature attribute set to True will be
            auto-derived. If provided an empty list, no features are derived.
            Any derived_features that are already in the 'features' list will
            not be derived since their values are being explicitly provided.
        input_is_substituted : bool, default False
            if True assumes provided nominal feature values have
            already been substituted.
        progress_callback : callable, optional
            A callback method that will be called before each
            batched call to train and at the end of training. The method is
            given a ProgressTimer containing metrics on the progress and timing
            of the train operation.
        series : str, optional
            Name of the series to pull features and case values
            from internal series storage. If specified, trains on all cases
            that are stored in the internal series store for the specified
            series. The trained feature set is the combined features from
            storage and the passed in features. If cases is of length one,
            the value(s) of this case are appended to all cases in the series.
            If cases is the same length as the series, the value of each case
            in cases is applied in order to each of the cases in the series.
        train_weights_only : bool, default False
            When true, and accumulate_weight_feature is provided,
            will accumulate all of the cases' neighbor weights instead of
            training the cases into the model.
        validate : bool, default True
            Whether to validate the data against the provided feature
            attributes. Issues warnings if there are any discrepancies between
            the data and the features dictionary.
        """
        self._auto_resolve_trainee(trainee_id)
        feature_attributes = self.trainee_cache.get(trainee_id).features

        # Make sure single table dicts are wrapped by SingleTableFeatureAttributes
        if isinstance(feature_attributes, Dict) and not isinstance(feature_attributes,
                                                                   MultiTableFeatureAttributes):
            feature_attributes = SingleTableFeatureAttributes(feature_attributes, {})

        # Check to see if the feature attributes still generally describe
        # the data, and warn the user if they do not
        if isinstance(cases, DataFrame) and validate:
            try:
                feature_attributes.validate(cases)
            except NotImplementedError:
                # MultiTableFeatureAttributes does not yet support DataFrame validation
                pass

        # See if any features were inferred to have data that is unsupported by the OS.
        # Issue a warning and drop the feature before training, if so.
        unsupported_features = []
        if isinstance(feature_attributes, MultiTableFeatureAttributes):
            for stfa in feature_attributes.values():
                unsupported_features = [feat for feat in stfa.keys() if stfa.has_unsupported_data(feat)]
        elif isinstance(feature_attributes, SingleTableFeatureAttributes):
            unsupported_features = [feat for feat in feature_attributes.keys()
                                    if feature_attributes.has_unsupported_data(feat)]
        for feature in unsupported_features:
            warnings.warn(f'Ignoring feature {feature} as it contains values that are too '
                          'large or small for your operating system. Please evaluate the '
                          'bounds for this feature.')
            cases.drop(feature, axis=1, inplace=True)

        validate_list_shape(features, 1, "features", "str")
        if self.verbose:
            print(f'Training session(s) on trainee with id: {trainee_id}')

        validate_list_shape(cases, 2, "cases", "list", allow_none=False)
        if features is None:
            features = internals.get_features_from_data(cases)
        cases = serialize_cases(cases, features, feature_attributes, warn=True)

        auto_analyze = False

        with ProgressTimer(len(cases)) as progress:
            gen_batch_size = None
            if series is not None:
                # If training series, always send full size
                batch_size = len(cases)
            if not batch_size:
                # Scale the batch size automatically
                batch_scaler = self.batch_scaler_class(100, progress)
                batch_scaler.size_limits = (1, 250_000)
                gen_batch_size = batch_scaler.gen_batch_size()
                batch_size = next(gen_batch_size, None)

            while not progress.is_complete and batch_size:
                if isinstance(progress_callback, Callable):
                    progress_callback(progress)
                start = progress.current_tick
                end = progress.current_tick + batch_size
                response = self.howso.train(
                    trainee_id,
                    ablatement_params=ablatement_params,
                    accumulate_weight_feature=accumulate_weight_feature,
                    derived_features=derived_features,
                    features=features,
                    input_cases=cases[start:end],
                    input_is_substituted=input_is_substituted,
                    series=series,
                    session=self.active_session.id,
                    train_weights_only=train_weights_only
                )
                if response and response.get('status') == 'analyze':
                    auto_analyze = True
                if gen_batch_size is None:
                    progress.update(batch_size)
                else:
                    batch_size = next(gen_batch_size, None)

        # Final call to batch callback on completion
        if isinstance(progress_callback, Callable):
            progress_callback(progress)

        # Add session metadata to trainee
        self.howso.set_session_metadata(
            trainee_id,
            self.active_session.id,
            self.active_session
        )

        self._auto_persist_trainee(trainee_id)

        # kick off auto-analyze if the train response requests it
        if auto_analyze:
            self.auto_analyze(trainee_id)

    def impute(
        self,
        trainee_id: str,
        features: Optional[Iterable[str]] = None,
        features_to_impute: Optional[Iterable[str]] = None,
        batch_size: int = 1
    ):
        """
        Impute, or fill in the missing values, for the specified features.

        If no 'features' are specified, will use all features in the trainee
        for imputation. If no 'features_to_impute' are specified, will impute
        all features specified by 'features'.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to impute.
        features: iterable of str, optional
            An iterable of feature names to use for imputation.

            If not specified, all features will be used imputed.
        features_to_impute: iterable of str, optional
            An iterable of feature names to impute
            If not specified, features will be used (see above)
        batch_size: int, default 1
            Larger batch size will increase accuracy and decrease speed.
            Batch size indicates how many rows to fill before recomputing
            conviction.

            The default value (which is 1) should return the best accuracy but
            might be slower. Higher values should improve performance but may
            decrease accuracy of results.
        """
        self._auto_resolve_trainee(trainee_id)
        validate_list_shape(features, 1, "features", "str")
        validate_list_shape(features_to_impute, 1, "features_to_impute", "str")
        if self.verbose:
            print(f'Imputing trainee with id: {trainee_id}')
        self.howso.impute(
            trainee_id,
            session=self.active_session.id,
            features=features,
            features_to_impute=features_to_impute,
            batch_size=batch_size
        )
        self._auto_persist_trainee(trainee_id)

    def remove_cases(
        self,
        trainee_id: str,
        num_cases: int,
        *,
        case_indices: Optional[Iterable[Tuple[str, int]]] = None,
        condition: Optional[Dict[str, object]] = None,
        condition_session: Optional[str] = None,
        distribute_weight_feature: Optional[str] = None,
        precision: Optional[Literal["exact", "similar"]] = None,
        preserve_session_data: bool = False
    ) -> int:
        """
        Removes training cases from a Trainee.

        The training cases will be completely purged from the model and
        the model will behave as if it had never been trained with them.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to remove cases from.
        num_cases : int
            The number of cases to remove; minimum 1 case must be removed.
            Ignored if case_indices is specified.
        case_indices : list of tuples
            A list of tuples containing session ID and session training index
            for each case to be removed.
        condition : dict of str to object, optional
            The condition map to select the cases to remove that meet all the
            provided conditions. Ignored if case_indices is specified.

            .. NOTE::
                The dictionary keys are the feature name and values are one of:

                    - None
                    - A value, must match exactly.
                    - An array of two numeric values, specifying an inclusive
                      range. Only applicable to continuous and numeric ordinal
                      features.
                    - An array of string values, must match any of these values
                      exactly. Only applicable to nominal and string ordinal
                      features.

            .. TIP::
                Example 1 - Remove all values belonging to `feature_name`::

                    criteria = {"feature_name": None}

                Example 2 - Remove cases that have the value 10::

                    criteria = {"feature_name": 10}

                Example 3 - Remove cases that have a value in range [10, 20]::

                    criteria = {"feature_name": [10, 20]}

                Example 4 - Remove cases that match one of ['a', 'c', 'e']::

                    condition = {"feature_name": ['a', 'c', 'e']}

        condition_session : str, optional
            If specified, ignores the condition and operates on cases for
            the specified session id. Ignored if case_indices is specified.
        distribute_weight_feature : str, optional
            When specified, will distribute the removed cases' weights
            from this feature into their neighbors.
        precision : {"exact", "similar"}, optional
            The precision to use when moving the cases, defaults to "exact".
            Ignored if case_indices is specified.
        preserve_session_data : bool, default False
            When True, will remove cases without cleaning up session data.

        Returns
        -------
        int
            The number of cases removed.

        Raises
        ------
        ValueError
            If `num_cases` is not at least 1.
        """
        self._auto_resolve_trainee(trainee_id)
        if num_cases < 1:
            raise ValueError('num_cases must be a value greater than 0')
        if self.verbose:
            print(f'Removing case(s) in trainee with id: {trainee_id}')

        if isinstance(precision, str):
            if precision not in self.SUPPORTED_PRECISION_VALUES:
                warnings.warn(self.INCORRECT_PRECISION_VALUE_WARNING)

        # Convert session instance to id
        if (
            isinstance(condition, dict) and
            isinstance(condition.get('.session'), Session)
        ):
            condition['.session'] = condition['.session'].id

        result = self.howso.remove_cases(
            trainee_id,
            case_indices=case_indices,
            condition=condition,
            condition_session=condition_session,
            distribute_weight_feature=distribute_weight_feature,
            num_cases=num_cases,
            precision=precision,
            preserve_session_data=preserve_session_data,
            session=self.active_session.id
        )
        self._auto_persist_trainee(trainee_id)
        return result.get('count', 0)

    def edit_cases(
        self,
        trainee_id: str,
        feature_values: Union[List[object], DataFrame],
        *,
        case_indices: Optional[Iterable[Sequence[Union[str, int]]]] = None,
        condition: Optional[Dict[str, object]] = None,
        condition_session: Optional[str] = None,
        features: Optional[Iterable[str]] = None,
        num_cases: Optional[int] = None,
        precision: Optional[Literal["exact", "similar"]] = None,
    ) -> int:
        """
        Edit feature values for the specified cases.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to edit the cases of.
        feature_values : list of object or pandas.DataFrame
            The feature values to edit the case(s) with. If specified as a list,
            the order corresponds with the order of the `features` parameter.
            If specified as a DataFrame, only the first row will be used.
        case_indices : Iterable of Sequence[Union[str, int]], optional
            Iterable of Sequences containing the session id and index, where index
            is the original 0-based index of the case as it was trained into
            the session. This explicitly specifies the cases to edit. When
            specified, `condition` and `condition_session` are ignored.
        condition : dict, optional
            A condition map to select which cases to edit. Ignored when
            `case_indices` are specified.

            .. NOTE::
                The dictionary keys are the feature name and values are one of:

                    - None
                    - A value, must match exactly.
                    - An array of two numeric values, specifying an inclusive
                      range. Only applicable to continuous and numeric ordinal
                      features.
                    - An array of string values, must match any of these values
                      exactly. Only applicable to nominal and string ordinal
                      features.

        condition_session : str, optional
            If specified, ignores the condition and operates on all cases for
            the specified session.
        features : iterable of str, optional
            The names of the features to edit. Required when `feature_values`
            is not specified as a DataFrame.
        num_cases : int, default None
            The maximum amount of cases to edit. If not specified, the limit
            will be k cases if precision is "similar", or no limit if precision
            is "exact".
        precision : {"exact", "similar"}, optional
            The precision to use when moving the cases, defaults to "exact".

        Returns
        -------
        int
            The number of cases modified.
        """
        self._auto_resolve_trainee(trainee_id)
        cached_trainee = self.trainee_cache.get(trainee_id)

        if isinstance(precision, str):
            if precision not in self.SUPPORTED_PRECISION_VALUES:
                warnings.warn(self.INCORRECT_PRECISION_VALUE_WARNING)

        # Validate case_indices if provided
        if case_indices is not None:
            validate_case_indices(case_indices)

        # Serialize feature_values
        if feature_values is not None:
            if features is None:
                features = internals.get_features_from_data(
                    feature_values, data_parameter='feature_values')
            feature_values = serialize_cases(feature_values, features,
                                             cached_trainee.features)
            if feature_values:
                # Only a single case should be provided
                feature_values = feature_values[0]

        # Convert session instance to id
        if (
            isinstance(condition, dict) and
            isinstance(condition.get('.session'), Session)
        ):
            condition['.session'] = condition['.session'].id

        if self.verbose:
            print(f'Editing case(s) in trainee with id: {trainee_id}')
        result = self.howso.edit_cases(
            trainee_id,
            case_indices=case_indices,
            condition=condition,
            condition_session=condition_session,
            features=features,
            feature_values=feature_values,
            precision=precision,
            num_cases=num_cases,
            session=self.active_session.id
        )
        self._auto_persist_trainee(trainee_id)
        return result.get('count', 0)

    def get_trainee_sessions(self, trainee_id: str) -> List[Dict[str, str]]:
        """
        Get the sessions of a trainee.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to get the list of sessions from.

        Returns
        -------
        list of dict of str to str
            A list of dicts with keys "id" and "name" for each session
            in the Trainee.

        Examples
        --------
        >>> print(cl.get_trainee_sessions(trainee.id))
        [{'id': '6c35e481-fb49-4178-a96f-fe4b5afe7af4', 'name': 'default'}]
        """
        self._auto_resolve_trainee(trainee_id)
        if self.verbose:
            print(f'Getting sessions from trainee with id: {trainee_id}')
        sessions = self.howso.get_sessions(trainee_id, attributes=['name', ])
        if isinstance(sessions, Iterable):
            return sessions
        else:
            return []

    def delete_trainee_session(self, trainee_id: str, session: str):
        """
        Deletes a session from a trainee.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to delete the session from.
        session : str
            The id of the session to remove.
        """
        self._auto_resolve_trainee(trainee_id)
        if self.verbose:
            print(f'Deleting session {session} from trainee with id: '
                  f'{trainee_id}')
        self.howso.remove_session(trainee_id, session)
        self._auto_persist_trainee(trainee_id)

    def begin_session(
        self, name: str = "default", metadata: Optional[Dict] = None
    ) -> Session:
        """
        Begin a new session.

        Parameters
        ----------
        name : str, default "default"
            The name of the session.
        metadata : dict, optional
            Any key-value pair to store as custom metadata for the session.

        Returns
        -------
        howso.openapi.models.Session
            The new session instance.

        Raises
        ------
        TypeError
            If `name` is non-None and not a string or `metadata` is non-None
            and not a dictionary.
        """
        if not isinstance(name, str):
            raise TypeError("`name` must be a str")
        if metadata is not None and not isinstance(metadata, dict):
            raise TypeError("`metadata` must be a dict")

        if self.verbose:
            print('Starting new session')
        self._active_session = Session(
            id=str(uuid.uuid4()),
            name=name,
            metadata=metadata or dict(),
            created_date=datetime.utcnow(),
            modified_date=datetime.utcnow(),
        )
        return self._active_session

    def get_sessions(self, search_terms: Optional[str] = None) -> List[Session]:
        """
        Return a list of all accessible sessions.

        .. NOTE::
            Returns sessions from across all loaded trainees. (The metadata will
            include the `trainee_id` from which the session was retrieved from)

        Parameters
        ----------
        search_terms : str, optional
            Space or comma delimited search terms to filter results by.

        Returns
        -------
        list of howso.openapi.models.Session
            The listing of session instances.
        """
        if self.verbose:
            print('Getting listing of sessions')
        filter_terms = []
        filtered_sessions = []
        if search_terms:
            filter_terms = search_terms.replace(',', ' ').split(' ')

        for trainee_id in self.trainee_cache.ids():
            sessions = self.howso.get_sessions(
                trainee_id, attributes=list(Session.attribute_map))
            if not sessions:
                continue

            for session in sessions:
                if filter_terms:
                    # Filter by search terms
                    for term in filter_terms:
                        if term.lower() in session.get('name', '').lower():
                            instance = model_from_dict(Session, session)
                            instance.metadata = instance.metadata or dict()
                            instance.metadata['trainee_id'] = trainee_id
                            filtered_sessions.append(instance)
                            break
                else:
                    instance = model_from_dict(Session, session)
                    instance.metadata = instance.metadata or dict()
                    instance.metadata['trainee_id'] = trainee_id
                    filtered_sessions.append(instance)
        return sorted(filtered_sessions,
                      key=operator.attrgetter('created_date'),
                      reverse=True)

    def get_session(self, session_id: str) -> Session:
        """
        Retrieve a session.

        .. NOTE::
            If multiple trainees are loaded, the session will be retrieved
            from the most recently loaded trainee that contains the requested
            session. (The metadata will include the `trainee_id` from which
            the session was retrieved from)

        Parameters
        ----------
        session_id : str
            The id of the session to retrieve.

        Returns
        -------
        howso.openapi.models.Session
            The session instance.
        """
        if self.verbose:
            print(f'Getting session with id: {session_id}')

        if session_id == self.active_session.id:
            return self.active_session

        # Find session from most recently loaded trainee first
        loaded_trainees = list(self.trainee_cache.ids())
        loaded_trainees.reverse()

        session = None
        for trainee_id in loaded_trainees:
            try:
                session_data = self.howso.get_session_metadata(
                    trainee_id, session_id)
            except HowsoError:
                # When session is not found, continue
                continue
            session = model_from_dict(Session, session_data)
            # Include trainee_id in the metadata
            session.metadata = session.metadata or dict()
            session.metadata['trainee_id'] = trainee_id
            break
        if session is None:
            raise HowsoError("Session not found")
        return session

    def update_session(self, session_id: str, *, metadata: Optional[Dict] = None) -> Session:
        """
        Update a session.

        .. NOTE::
            Updates the session across all loaded trainees.

        Parameters
        ----------
        session_id : str
            The id of the session to update metadata for.
        metadata : dict, optional
            Any key-value pair to store as custom metadata for the session.

        Returns
        -------
        howso.openapi.models.Session
            The updated session instance.

        Raises
        ------
        TypeError
            If `metadata` is non-None and not a dictionary.
        HowsoError
            If `session_id` is not found for the active session or any of
            the session(s) of a loaded Trainees.
        """
        if metadata is not None and not isinstance(metadata, dict):
            raise TypeError("`metadata` must be a dict")
        if self.verbose:
            print(f'Updating session for session with id: {session_id}')

        updated_session = None
        modified_date = datetime.utcnow()
        metadata = metadata or dict()
        # We remove the trainee_id since this may have been set by the
        # get_session(s) methods and is not needed to be stored in the model.
        metadata.pop('trainee_id', None)

        def _update_session(instance):
            instance.metadata = instance.metadata or dict()
            instance.metadata = metadata or dict()
            instance.modified_date = modified_date
            return instance

        # Update session across all loaded trainees
        for trainee_id in self.trainee_cache.ids():
            try:
                session_data = self.howso.get_session_metadata(
                    trainee_id, session_id)
            except HowsoError:
                # When session is not found, continue
                continue
            session = model_from_dict(Session, session_data)
            session = _update_session(session)
            self.howso.set_session_metadata(trainee_id, session_id, session)
            updated_session = session

        if self.active_session.id == session_id:
            # Update active session
            self._active_session = _update_session(self.active_session)
            if updated_session is None:
                updated_session = self.active_session
        elif updated_session is None:
            # If session_id was not for the active session or any session
            # of loaded trainees, raise error
            raise HowsoError("Session not found")
        return updated_session

    def react_series(  # noqa: C901
        self,
        trainee_id: str,
        *,
        action_features: Optional[Iterable[str]] = None,
        actions: Optional[Union[List[List[object]], DataFrame]] = None,
        case_indices: Optional[Iterable[Sequence[Union[str, int]]]] = None,
        contexts: Optional[Union[List[List[object]], DataFrame]] = None,
        context_features: Optional[Iterable[str]] = None,
        continue_series: Optional[bool] = False,
        derived_action_features: Optional[Iterable[str]] = None,
        derived_context_features: Optional[Iterable[str]] = None,
        desired_conviction: Optional[float] = None,
        details: Optional[Dict] = None,
        feature_bounds_map: Optional[Dict] = None,
        final_time_steps: Optional[Union[List[object], List[List[object]]]] = None,
        generate_new_cases: Literal["always", "attempt", "no"] = "no",
        init_time_steps: Optional[Union[List[object], List[List[object]]]] = None,
        initial_features: Optional[Iterable[str]] = None,
        initial_values: Optional[Union[List[object], List[List[object]]]] = None,
        input_is_substituted: bool = False,
        leave_case_out: bool = False,
        max_series_lengths: Optional[List[int]] = None,
        new_case_threshold: Literal["max", "min", "most_similar"] = "min",
        num_series_to_generate: int = 1,
        ordered_by_specified_features: bool = False,
        output_new_series_ids: bool = True,
        preserve_feature_values: Optional[Iterable[str]] = None,
        progress_callback: Optional[Callable] = None,
        series_context_features: Optional[Iterable[str]] = None,
        series_context_values: Optional[Union[List[object], List[List[object]]]] = None,
        series_id_tracking: Literal["dynamic", "fixed", "no"] = "fixed",
        series_stop_maps: Optional[List[Dict[str, Dict]]] = None,
        substitute_output: bool = True,
        suppress_warning: bool = False,
        use_case_weights: bool = False,
        use_regional_model_residuals: bool = True,
        weight_feature: Optional[str] = None
    ) -> Dict:
        """
        React in a series until a series_stop_map condition is met.

        Aggregates rows of data corresponding to the specified context, action,
        derived_context and derived_action features, utilizing previous rows to
        derive values as necessary. Outputs a dict of "action_features" and
        corresponding "series" where "series" is the completed 'matrix' for the
        corresponding `action_features` and `derived_action_features`.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to react to.
        num_series_to_generate : int, default 1
            The number of series to generate.
        final_time_steps : list of object, optional
            The time steps at which to end synthesis. Time-series
            only. Must provide either one for all series, or exactly one per
            series.
        init_time_steps : list of object, optional
            The time steps at which to begin synthesis. Time-series
            only. Must provide either one for all series, or exactly one per
            series.
        initial_features : iterable of str, optional
            List of features to condition just the first case in a
            series, overwrites context_features and derived_context_features
            for that first case. All specified initial features must be in one
            of: context_features, action_features, derived_context_features or
            derived_action_features. If provided a value that isn't in one of
            those lists, it will be ignored.
        initial_values : list of list of object, optional
            2d list of values corresponding to the initial_features,
            used to condition just the first case in each series. Must provide
            either one for all series, or exactly one per series.
        series_stop_maps : list of dict of dict, optional
            A dictionary of feature name to stop conditions. Must provide either
            one for all series, or exactly one per series.

            .. TIP::
                Stop series when value exceeds max or is smaller than min::

                    {"feature_name":  {"min" : 1, "max": 2}}

                Stop series when feature value matches any of the values
                listed::

                    {"feature_name":  {"values": ["val1", "val2"]}}

        max_series_lengths : list of int, optional
            maximum size a series is allowed to be.  Default is
            3 * model_size, a 0 or less is no limit. Must provide
            either one for all series, or exactly one per series.
        continue_series : bool, default False
            When True will attempt to continue existing series instead of
            starting new series. If `initial_values` provide series IDs, it
            will continue those explicitly specified IDs, otherwise it will
            randomly select series to continue.
            .. note::

                Terminated series with terminators cannot be continued and
                will result in null output.

        derived_context_features : iterable of str, optional
            List of context features whose values should be computed
            from the entire series in the specified order. Must be
            different than context_features.
        derived_action_features : iterable of str, optional
            List of action features whose values should be computed
            from the resulting last row in series, in the specified order.
            Must be a subset of action_features.

            .. note::

                Both of these derived feature lists rely on the features'
                "derived_feature_code" attribute to compute the values. If
                "derived_feature_code" attribute references non-existing
                feature indices, the derived value will be null.

        series_context_features : iterable of str, optional
            List of context features corresponding to
            series_context_values, if specified must not overlap with any
            initial_features or context_features.
        series_context_values : list of list of list of object or list of DataFrame, optional
            3d-list of context values, one for each feature for each
            row for each series. If specified, max_series_lengths are ignored.
        output_new_series_ids : bool, default True
            If True, series ids are replaced with unique values on output.
            If False, will maintain or replace ids with existing trained values,
            but also allows output of series with duplicate existing ids.
        series_id_tracking : {"dynamic", "fixed", "no"}, default "fixed"
            Controls how closely generated series should follow existing series (plural).

            Choices are: "fixed" , "dynamic" or "no":

            - If "fixed", tracks the particular relevant series ID.
            - If "dynamic", tracks the particular relevant series ID, but is
              allowed to change the series ID that it tracks based on its
              current context.
            - If "no", does not track any particular series ID.

        progress_callback : callable, optional
            A callback method that will be called before each
            batched call to react series and at the end of reacting. The method
            is given a ProgressTimer containing metrics on the progress and
            timing of the react series operation, and the batch result.
        contexts: list of list of object or DataFrame
            See parameter ``contexts`` in :meth:`HowsoDirectClient.react`.
        action_features: iterable of str
            See parameter ``action_features`` in :meth:`HowsoDirectClient.react`.
        actions: list of list of object or DataFrame
            See parameter ``actions`` in :meth:`HowsoDirectClient.react`.
        context_features: iterable of str
            See parameter ``context_features`` in :meth:`HowsoDirectClient.react`.
        input_is_substituted : bool, default False
            See parameter ``input_is_substituted`` in :meth:`HowsoDirectClient.react`.
        substitute_output : bool
            See parameter ``substitute_output`` in :meth:`HowsoDirectClient.react`.
        details: dict, optional
            See parameter ``details`` in :meth:`HowsoDirectClient.react`.
        desired_conviction: float
            See parameter ``desired_conviction`` in :meth:`HowsoDirectClient.react`.
        weight_feature : str
            See parameter ``weight_feature`` in :meth:`HowsoDirectClient.react`.
        use_case_weights : bool
            See parameter ``use_case_weights`` in :meth:`HowsoDirectClient.react`.
        case_indices: iterable of sequence of str, int
            See parameter ``case_indices`` in :meth:`HowsoDirectClient.react`.
        preserve_feature_values : iterable of str
            See parameter ``preserve_feature_values`` in :meth:`HowsoDirectClient.react`.
        new_case_threshold : str
            See parameter ``new_case_threshold`` in :meth:`HowsoDirectClient.react`.
        leave_case_out : bool
            See parameter ``leave_case_out`` in :meth:`HowsoDirectClient.react`.
        use_regional_model_residuals : bool
            See parameter ``use_regional_model_residuals`` in :meth:`HowsoDirectClient.react`.
        feature_bounds_map: dict of dict
            See parameter ``feature_bounds_map`` in :meth:`HowsoDirectClient.react`.
        generate_new_cases : {"always", "attempt", "no"}
            See parameter ``generate_new_cases`` in :meth:`HowsoDirectClient.react`.
        ordered_by_specified_features : bool
            See parameter ``ordered_by_specified_features`` in :meth:`HowsoDirectClient.react`.
        suppress_warning : bool
            See parameter ``suppress_warning`` in :meth:`HowsoDirectClient.react`.

        Returns
        -------
        dict
            A dictionary with keys `action_features` and `series`. Where
            `series` is a 2d list of values (rows of data per series), and
            `action_features` is the list of all action features
            (specified and derived).

            Example output for 2 short series with 3 features:

            .. code-block::

                {
                    'action_features': ['id','x','y'],
                    'series': [
                        [ ["A", 1, 2], ["A", 2, 2] ],
                        [ ["B", 4, 4], ["B", 6, 7], ["B", 8, 9] ]
                    ]
                }

        Raises
        ------
        ValueError
            If the number of provided context values does not match the length of
            context features.

            If `series_context_values` is not a 3d list of objects.

            If `derived_action_features` is not a subset of `action_features`.

            If `new_case_threshold` is not one of {"max", "min", "most_similar"}.
        HowsoError
            If `num_series_to_generate` is not an integer greater than 0.
        """
        self._auto_resolve_trainee(trainee_id)
        feature_attributes = self.trainee_cache.get(trainee_id).features

        validate_list_shape(initial_features, 1, "initial_features", "str")
        validate_list_shape(initial_values, 2, "initial_values",
                            "list of object")
        validate_list_shape(initial_features, 1, "max_series_lengths", "num")
        validate_list_shape(series_stop_maps, 1, "series_stop_maps", "dict")

        validate_list_shape(initial_features, 1, "series_context_features", "str")
        if series_context_values and num_list_dimensions(series_context_values) != 3:
            raise ValueError(
                "Improper shape of `series_context_values` values passed. "
                "`series_context_values` must be a 3d list of object.")

        action_features, actions, context_features, contexts = (
            self._preprocess_react_parameters(
                action_features=action_features,
                actions=actions,
                case_indices=case_indices,
                context_features=context_features,
                contexts=contexts,
                desired_conviction=desired_conviction,
                preserve_feature_values=preserve_feature_values,
                trainee_id=trainee_id
            )
        )

        if action_features is not None and derived_action_features is not None:
            if not set(derived_action_features).issubset(set(action_features)):
                raise ValueError(
                    'Specified \'derived_action_features\' must be a subset of '
                    '\'action_features\'.')

        serialized_series_context_values = None
        if series_context_values:
            serialized_series_context_values = []
            for series in series_context_values:
                if series_context_features is None:
                    series_context_features = internals.get_features_from_data(
                        data=series,
                        data_parameter="series_context_values",
                        features_parameter="series_context_features")
                serialized_series_context_values.append(
                    serialize_cases(series, series_context_features,
                                    feature_attributes))

        if new_case_threshold not in [None, "min", "max", "most_similar"]:
            raise ValueError(
                f"The value '{new_case_threshold}' specified for the parameter "
                "`new_case_threshold` is not valid. It accepts one of the"
                " following values - ['min', 'max', 'most_similar',]"
            )

        if initial_values is not None and initial_features is None:
            initial_features = internals.get_features_from_data(
                initial_values,
                data_parameter='initial_values',
                features_parameter='initial_features')
        initial_values = serialize_cases(initial_values, initial_features,
                                         feature_attributes)

        if desired_conviction is None:
            if contexts is not None:
                for context in contexts:
                    if context is not None and \
                            (len(context) != len(context_features)):
                        raise ValueError(
                            "Number of provided context values in `context` "
                            "does not match length of `context_features`."
                        )
                total_size = len(contexts)
            else:
                total_size = len(case_indices)

            react_params = {
                "action_features": action_features,
                "action_values": actions,
                "context_features": context_features,
                "context_values": contexts,
                "continue_series": continue_series,
                "initial_features": initial_features,
                "initial_values": initial_values,
                "series_stop_maps": series_stop_maps,
                "max_series_lengths": max_series_lengths,
                "derived_context_features": derived_context_features,
                "derived_action_features": derived_action_features,
                "series_context_features": series_context_features,
                "series_context_values": serialized_series_context_values,
                "case_indices": case_indices,
                "preserve_feature_values": preserve_feature_values,
                "new_case_threshold": new_case_threshold,
                "input_is_substituted": input_is_substituted,
                "substitute_output": substitute_output,
                "weight_feature": weight_feature,
                "use_case_weights": use_case_weights,
                "leave_case_out": leave_case_out,
                "details": details,
                "series_id_tracking": series_id_tracking,
                "output_new_series_ids": output_new_series_ids,
            }

        else:
            if (
                not isinstance(num_series_to_generate, int) or
                num_series_to_generate <= 0
            ):
                raise HowsoError("`num_series_to_generate` must be an integer "
                                 "greater than 0.")
            total_size = num_series_to_generate
            if total_size > self._react_generative_batch_threshold:
                # Do not send details for generative reacts over threshold
                details = None

            context_features, contexts = \
                self._preprocess_generate_parameters(
                    trainee_id,
                    action_features=action_features,
                    context_features=context_features,
                    contexts=contexts,
                    desired_conviction=desired_conviction,
                    num_cases_to_generate=num_series_to_generate,
                    case_indices=case_indices
                )

            react_params = {
                "num_series_to_generate": num_series_to_generate,
                "action_features": action_features,
                "context_features": context_features,
                "context_values": contexts,
                "continue_series": continue_series,
                "initial_features": initial_features,
                "initial_values": initial_values,
                "final_time_steps": final_time_steps,
                "init_time_steps": init_time_steps,
                "series_stop_maps": series_stop_maps,
                "max_series_lengths": max_series_lengths,
                "derived_context_features": derived_context_features,
                "derived_action_features": derived_action_features,
                "series_context_features": series_context_features,
                "series_context_values": serialized_series_context_values,
                "use_regional_model_residuals": use_regional_model_residuals,
                "desired_conviction": desired_conviction,
                "feature_bounds_map": feature_bounds_map,
                "generate_new_cases": generate_new_cases,
                "ordered_by_specified_features": ordered_by_specified_features,
                "input_is_substituted": input_is_substituted,
                "substitute_output": substitute_output,
                "weight_feature": weight_feature,
                "use_case_weights": use_case_weights,
                "preserve_feature_values": preserve_feature_values,
                "new_case_threshold": new_case_threshold,
                "case_indices": case_indices,
                "leave_case_out": leave_case_out,
                "details": details,
                "series_id_tracking": series_id_tracking,
                "output_new_series_ids": output_new_series_ids,
            }

        if self._should_react_batch(react_params, total_size):
            if self.verbose:
                print(f'Batch series reacting on trainee with id: {trainee_id}')
            response = self._batch_react_series(
                trainee_id, react_params, total_size=total_size,
                progress_callback=progress_callback)
        else:
            if self.verbose:
                print(f'Series reacting on trainee with id: {trainee_id}')
            with ProgressTimer(total_size) as progress:
                if isinstance(progress_callback, Callable):
                    progress_callback(progress, None)
                response = self._react_series(trainee_id, react_params)
                progress.update(total_size)

            if isinstance(progress_callback, Callable):
                progress_callback(progress, response)

        # If the number of series generated is less then requested, raise
        # warning, for generative reacts
        if desired_conviction is not None:
            len_action = len(response['series'])
            internals.insufficient_generation_check(
                num_series_to_generate, len_action,
                suppress_warning=suppress_warning
            )

        return response

    def _batch_react_series(  # noqa: C901
        self,
        trainee_id: str,
        react_params: dict,
        *,
        total_size: int,
        progress_callback: Optional[Callable] = None
    ):
        """
        Make react series requests in batch.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to react to.
        react_params : dict
            The request object.
        total_size : int
            The total size of the data that will be batched.
        progress_callback : callable, optional
            A function to be called during batching to retrieve or
            report progress metrics.

        Returns
        -------
        dict
            The `react_series` response.
        """
        temp_result = None
        accumulated_result = {'series': []}

        actions = react_params.get('action_values')
        contexts = react_params.get('context_values')
        case_indices = react_params.get('case_indices')
        initial_values = react_params.get('initial_values')
        max_series_lengths = react_params.get('max_series_lengths')
        series_context_values = react_params.get('series_context_values')
        series_stop_maps = react_params.get('series_stop_maps')

        with ProgressTimer(total_size) as progress:
            if self.howso.amlg.library_postfix[1:] == 'mt':
                start_batch_size = max(multiprocessing.cpu_count(), 1)
            else:
                start_batch_size = 1
            batch_scaler = self.batch_scaler_class(start_batch_size, progress)
            gen_batch_size = batch_scaler.gen_batch_size()
            batch_size = next(gen_batch_size, None)

            while not progress.is_complete and batch_size is not None:
                if isinstance(progress_callback, Callable):
                    progress_callback(progress, temp_result)
                batch_start = progress.current_tick
                batch_end = progress.current_tick + batch_size

                if actions is not None and len(actions) > 1:
                    react_params['action_values'] = actions[
                        batch_start:batch_end]
                if contexts is not None and len(contexts) > 1:
                    react_params['context_values'] = contexts[
                        batch_start:batch_end]
                if case_indices is not None and len(case_indices) > 1:
                    react_params['case_indices'] = (
                        case_indices[batch_start:batch_end])
                if initial_values is not None and len(initial_values) > 1:
                    react_params['initial_values'] = (
                        initial_values[batch_start:batch_end])
                if (
                    max_series_lengths is not None and
                    len(max_series_lengths) > 1
                ):
                    react_params['max_series_lengths'] = (
                        max_series_lengths[batch_start:batch_end])
                if (
                    series_context_values is not None and
                    len(series_context_values) > 1
                ):
                    react_params['series_context_values'] = (
                        series_context_values[batch_start:batch_end])
                if series_stop_maps is not None and len(series_stop_maps) > 1:
                    react_params['series_stop_maps'] = (
                        series_stop_maps[batch_start:batch_end])

                if react_params.get('desired_conviction') is not None:
                    react_params['num_series_to_generate'] = batch_size
                temp_result = self._react_series(trainee_id, react_params)

                internals.accumulate_react_result(accumulated_result,
                                                  temp_result)
                batch_size = next(gen_batch_size, None)

        # Final call to callback on completion
        if isinstance(progress_callback, Callable):
            progress_callback(progress, temp_result)

        return accumulated_result

    def _react_series(self, trainee_id, react_params):
        """
        Make a single react series request.

        Parameters
        ----------
        trainee_id : str
            The id of the trainee.
        react_params : dict
            The request parameters.

        Returns
        -------
        dict
            The react series response.
        """
        batch_result = self.howso.batch_react_series(trainee_id, **react_params)

        if batch_result is None or batch_result.get('series') is None:
            raise ValueError('Invalid parameters passed to react_series.')

        ret = dict()

        ret['action_features'] = batch_result.pop('action_features') or []
        ret['series'] = replace_doublemax_with_infinity(
            batch_result.pop('series'))

        return ret

    def append_to_series_store(
        self,
        trainee_id: str,
        series: str,
        contexts: Union[List[List[object]], DataFrame],
        *,
        context_features: Optional[Iterable[str]] = None
    ):
        """
        Append the specified contexts to a series store.

        For use with train series.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to append to.
        series : str
            The name of the series store to append to.
        contexts : list of list of object or pandas.DataFrame
            The list of list of context values to append to the series.
        context_features : iterable of str, optional
            The list of feature names for contexts.
        """
        self._auto_resolve_trainee(trainee_id)
        cached_trainee = self.trainee_cache.get(trainee_id)

        validate_list_shape(contexts, 2, "contexts", "list of object",
                            allow_none=False)

        if context_features is None:
            default_context_features = cached_trainee.default_context_features
            context_features = internals.get_features_from_data(
                contexts,
                default_features=default_context_features,
                data_parameter='contexts',
                features_parameter='context_features'
            )

        if len(np.array(contexts).shape) == 1 and len(contexts) > 0:
            contexts = [contexts]

        # Preprocess contexts
        contexts = serialize_cases(
            contexts, context_features, cached_trainee.features)

        if self.verbose:
            print('Appending to series store for trainee with id: '
                  f'{trainee_id}, and series: {series}')

        self.howso.append_to_series_store(trainee_id,
                                          context_features=context_features,
                                          contexts=contexts,
                                          series=series)

    def react(  # noqa: C901
        self,
        trainee_id: str,
        *,
        action_features: Optional[Iterable[str]] = None,
        actions: Optional[Union[List[List[object]], DataFrame]] = None,
        allow_nulls: bool = False,
        case_indices: Optional[Iterable[Sequence[Union[str, int]]]] = None,
        contexts: Optional[Union[List[List[object]], DataFrame]] = None,
        context_features: Optional[Iterable[str]] = None,
        derived_action_features: Optional[Iterable[str]] = None,
        derived_context_features: Optional[Iterable[str]] = None,
        desired_conviction: Optional[float] = None,
        details: Optional[Dict] = None,
        feature_bounds_map: Optional[Dict] = None,
        generate_new_cases: Literal["always", "attempt", "no"] = "no",
        input_is_substituted: bool = False,
        into_series_store: Optional[str] = None,
        leave_case_out: bool = False,
        new_case_threshold: Literal["max", "min", "most_similar"] = "min",
        num_cases_to_generate: int = 1,
        ordered_by_specified_features: bool = False,
        preserve_feature_values: Optional[Iterable[str]] = None,
        progress_callback: Optional[Callable] = None,
        substitute_output: bool = True,
        suppress_warning: bool = False,
        use_case_weights: bool = False,
        use_regional_model_residuals: bool = True,
        weight_feature: Optional[str] = None,
    ) -> Dict:
        r"""
        React to supplied values and cases contained within the Trainee.

        If desired_conviction is not specified, executes a discriminative
        react: provided a list of context values, the trainee reacts to the
        model and produces predictions for the specified actions. If
        desired_conviction is specified, executes a generative react,
        produces action_values for the specified action_features conditioned
        on the optionally provided contexts.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to react to.

        contexts : list of list of object or DataFrame, optional
            A 2d list of context values to react to.
            If None for discriminative react, it is assumed that `session`
            and `session_id` keys are set in the `details`.

            >>> contexts = [[1, 2, 3], [4, 5, 6]]

        action_features : iterable of str, optional
            An iterable of feature names to treat as action features during
            react.

            >>> action_features = ['rain_chance', 'is_sunny']

        actions : list of list of object or DataFrame, optional
            One or more action values to use for action features.
            If specified, will only return the specified explanation
            details for the given actions. (Discriminative reacts only)

            >>> actions = [[1, 2, 3], [4, 5, 6]]

        allow_nulls : bool, default False
            When true will allow return of null values if there
            are nulls in the local model for the action features, applicable
            only to discriminative reacts.

        context_features : iterable of str, optional
            An iterable of feature names to treat as context features during
            react.

            >>> context_features = ['temperature', 'humidity', 'dew_point',
            ...                     'barometric_pressure']
        derived_context_features : iterable of str, optional
            An iterable of feature names whose values should be computed
            from the provided context in the specified order. Must be different
            than context_features.
        derived_action_features : iterable of str, optional
            An iterable of feature names whose values should be computed
            after generation from the generated case prior to output, in the
            specified order. Must be a subset of action_features.

            .. note::

                Both of these derived feature lists rely on the features'
                "derived_feature_code" attribute to compute the values. If
                'derived_feature_code' attribute is undefined or references
                non-0 feature indices, the derived value will be null.

        input_is_substituted : bool, default False
            if True assumes provided categorical (nominal or
            ordinal) feature values have already been substituted.
        substitute_output : bool, default True
            If False, will not substitute categorical feature
            values. Only applicable if a substitution value map has been set.
        details : dict, optional
            If details are specified, the response will
            contain the requested explanation data along with the reaction.
            Below are the valid keys and data types for the different audit
            details. Omitted keys, values set to None, or False values for
            Booleans will not be included in the audit data returned.

            - influential_cases: bool, optional
                If True outputs the most influential cases and their influence
                weights based on the surprisal of each case relative to the
                context being predicted among the cases. Uses only the context
                features of the reacted case.

            - influential_cases_familiarity_convictions:  bool, optional
                If True outputs familiarity conviction of addition for each of
                the influential cases.

            - influential_cases_raw_weights: bool, optional
                If True outputs the surprisal for each of the influential cases.

            - hypothetical_values: dict, optional
                A dictionary of feature name to feature value. If specified,
                shows how a prediction could change in a what-if scenario where
                the influential cases' context feature values are replaced with
                the specified values.  Iterates over all influential cases,
                predicting the action features each one using the updated
                hypothetical values. Outputs the predicted arithmetic over the
                influential cases for each action feature.

            - most_similar_cases: bool, optional
                If True outputs an automatically determined (when
                'num_most_similar_cases' is not specified) relevant number of
                similar cases, which will first include the influential cases.
                Uses only the context features of the reacted case.

            - num_most_similar_cases: int, optional
                Outputs this manually specified number of most similar cases,
                which will first include the influential cases.

                NOTE: The maximum number of cases that can be queried is
                `1000`.

            - num_most_similar_case_indices : int, optional
                Outputs the specified number of most similar case indices when
                'distance_ratio' is also set to True.

                NOTE: The maximum number of cases that can be queried is
                '1000'.

            - num_robust_influence_samples_per_case : int, optional
                Specifies the number of robust samples to use for each case.
                Applicable only for computing robust feature contributions or
                robust case feature contributions. Defaults to 2000. Higher
                values will take longer but provide more stable results.

            - boundary_cases: bool, optional
                If True outputs an automatically determined (when
                'num_boundary_cases' is not specified) relevant number of
                boundary cases. Uses both context and action features of the
                reacted case to determine the counterfactual boundary based on
                action features, which maximize the dissimilarity of action
                features while maximizing the similarity of context features.
                If action features aren't specified, uses familiarity conviction
                to determine the boundary instead.

            - num_boundary_cases: int, optional
                Outputs this manually specified number of boundary cases.

                NOTE: The maximum number of cases that can be queried is
                '1000'.

            - boundary_cases_familiarity_convictions: bool, optional
                If True outputs familiarity conviction of addition for each of
                the boundary cases.

            - distance_ratio: bool, optional
                If True outputs the ratio of distance (relative surprisal)
                between this reacted case and its nearest case to the minimum
                distance (relative surprisal) in between the closest two cases
                in the local area. All distances are computed using only the
                specified context features.

            - distance_contribution: bool, optional
                If True outputs the distance contribution (expected total
                surprisal contribution) for the reacted case. Uses both context
                and action feature values.

            - similarity_conviction: bool, optional
                If True outputs similarity conviction for the reacted case.
                Uses both context and action feature values as the case values
                for all computations. This is defined as expected (local)
                distance contribution divided by reacted case distance
                contribution.

            - outlying_feature_values: bool, optional
                If True outputs the reacted case's context feature values that
                are outside the min or max of the corresponding feature values
                of all the cases in the local model area. Uses only the context
                features of the reacted case to determine that area.

            - categorical_action_probabilities: bool, optional
                If True outputs probabilities for each class for the action.
                Applicable only to categorical action features.

            - observational_errors: bool, optional
                If True outputs observational errors for all features as
                defined in feature attributes.

            - robust_computation: bool, optional
                Default is False, uses leave-one-out for features (or cases,
                as needed) for all relevant computations. If True, uses
                uniform sampling from the power set of all combinations of
                features (or cases, as needed) instead.

            - feature_residuals: bool, optional
                If True outputs feature residuals for all (context and action)
                features locally around the prediction. Uses only the context
                features of the reacted case to determine that area. Relies on
                'robust_computation' parameter to determine whether to do
                standard or robust computation.

            - feature_mda: bool, optional
                If True outputs each context feature's mean decrease in
                accuracy of predicting the action feature given the context.
                Uses only the context features of the reacted case to determine
                that area. Relies on 'robust_computation' parameter to
                determine whether to do standard or robust computation.

            - feature_mda_ex_post: bool, optional
                If True outputs each context feature's mean decrease in
                accuracy of predicting the action feature as an explanation
                given that the specified prediction was already made as
                specified by the action value. Uses both context and action
                features of the reacted case to determine that area. Relies on
                'robust_computation' parameter to determine whether to do
                standard or robust computation.

            - feature_contributions: bool, optional
                If True outputs each context feature's differences between the
                predicted action feature value and the predicted action feature
                value if each context were not in the model for all context
                features in the local model area. Relies on 'robust_computation'
                parameter to determine whether to do standard or robust
                computation.

            - case_feature_contributions: bool, optional
                If True outputs each context feature's differences between the
                predicted action feature value and the predicted action feature
                value if each context feature were not in the model for all
                context features in this case, using only the values from this
                specific case. Relies on 'robust_computation' parameter to
                determine whether to do standard or robust computation.

            - case_mda: bool, optional
                If True outputs each influential case's mean decrease in
                accuracy of predicting the action feature in the local model
                area, as if each individual case were included versus not
                included. Uses only the context features of the reacted case to
                determine that area. Relies on 'robust_computation' parameter
                to determine whether to do standard or robust computation.

            - case_contributions: bool, optional
                If True outputs each influential case's differences between the
                predicted action feature value and the predicted action feature
                value if each individual case were not included. Uses only the
                context features of the reacted case to determine that area.
                Relies on 'robust_computation' parameter to determine whether
                to do standard or robust computation.

            - case_feature_residuals: bool, optional
                If True outputs feature residuals for all (context and action)
                features for just the specified case. Uses leave-one-out for
                each feature, while using the others to predict the left out
                feature with their corresponding values from this case. Relies
                on 'robust_computation' parameter to determine whether to do
                standard or robust computation.

            - local_case_feature_residual_convictions: bool, optional
                If True outputs this case's feature residual convictions for
                the region around the prediction. Uses only the context
                features of the reacted case to determine that region.
                Computed as: region feature residual divided by case feature
                residual. Relies on 'robust_computation' parameter to determine
                whether to do standard or robust computation.

            - global_case_feature_residual_convictions: bool, optional
                If True outputs this case's feature residual convictions for
                the global model. Computed as: global model feature residual
                divided by case feature residual. Relies on
                'robust_computation' parameter to determine whether to do
                standard or robust computation.

            >>> details = {'num_most_similar_cases': 5,
            ...            'feature_residuals': True}

        desired_conviction : float
            If specified will execute a generative react. If not
            specified will executed a discriminative react. Conviction is the
            ratio of expected surprisal to generated surprisal for each
            feature generated, valid values are in the range of
            :math:`(0, \\infty)`.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.
        use_case_weights : bool, default False
            If set to True will scale influence weights by each
            case's weight_feature weight.
        case_indices : Iterable of Sequence[Union[str, int]], defaults to None
            An Iterable of Sequences, of session id and index, where
            index is the original 0-based index of the case as it was trained
            into the session. If this case does not exist, discriminative react
            outputs null, generative react ignores it.
        preserve_feature_values : iterable of str
            List of features that will preserve their values from
            the case specified by case_indices, appending and overwriting the
            specified contexts as necessary.  For generative reacts, if
            case_indices isn't specified will preserve feature values of a
            random case.
        leave_case_out : bool, default False
            If set to True and specified along with case_indices,
            each individual react will respectively ignore the corresponding
            case specified by case_indices by leaving it out.
        into_series_store : str, optional
            The name of a series store. If specified, will store an internal
            record of all react contexts for this session and series to be used
            later with train series.
        use_regional_model_residuals : bool
            If false uses model feature residuals, if True
            recalculates regional model residuals.
        feature_bounds_map : dict of dict
            A mapping of feature names to the bounds for the
            feature values to be generated in. For continuous features this
            should be a numeric value, for datetimes this should be a datetime
            string. Min bounds should be equal to or smaller than max bounds,
            except when setting the bounds around the cycle length of a cyclic
            feature.(e.g., to allow 0 +/- 60 degrees, set min=300 and max=60).

            .. code-block::
                :caption: Example feature bounds map:

                {
                    "feature_a": {"min": 0},
                    "feature_b" : {"min": 1, "max": 5},
                    "feature_c": {"max": 1}
                }

        generate_new_cases : {"always", "attempt", "no"}, default "no"
            (Optional) Whether to generate new cases.

            This parameter takes in a string equal to one of the following:

            a. "attempt"

                `Synthesizer` attempts to generate new cases and
                if its not possible to generate a new case, it might
                generate cases in "no" mode (see point c.)
            b. "always"

                `Synthesizer` always generates new cases and
                if its not possible to generate a new case, it returns
                `None`.
            c. "no"

                `Synthesizer` generates data based on the
                `desired_conviction` specified and the generated data is
                not guaranteed to be a new case (that is, a case not found
                in original dataset.)

        ordered_by_specified_features : bool, default False
            If True order of generated feature values will match
            the order of specified features.
        num_cases_to_generate : int, default 1
            The number of cases to generate.
        suppress_warning : bool, defaults to False
            If True, warnings will not be displayed.
        progress_callback : callable, optional
            A callback method that will be called before each
            batched call to react and at the end of reacting. The method is
            given a ProgressTimer containing metrics on the progress and timing
            of the react operation, and the batch result.
        new_case_threshold : str, optional
            Distance to determine the privacy cutoff. If None,
            will default to "min".

            Possible values:

                - min: minimum distance in the original local space.
                - max: maximum distance in the original local space.
                - most_similar: distance between the nearest neighbor to the
                  nearest neighbor in the original space.

        Returns
        -------
        dict
            A dictionary with keys `action` and `explanation`. Where `action`
            is a list of dicts of action_features -> action_values, and
            `explanation` is a dict with the requested audit data.

            .. code-block::
                :caption: Example reaction for 2 contexts with 2 action features:

                {
                    'action': [{'size': 1, 'width': 1}, {'size': 2, 'width': 2}]
                    'explanation': {
                        'action_features': ['size', 'width'],
                        'distance_contribution': [3.45, 0.89],
                    }
                }

        Raises
        ------
        ValueError
            If `derived_action_features` is not a subset of `action_features`.

            If `new_case_threshold` is not one of {"max", "min", "most_similar"}.

            If the number of context values does not match the number of context features.
        HowsoError
            If `num_cases_to_generate` is not an integer greater than 0.
        """
        self._auto_resolve_trainee(trainee_id)
        action_features, actions, context_features, contexts = (
            self._preprocess_react_parameters(
                action_features=action_features,
                actions=actions,
                case_indices=case_indices,
                context_features=context_features,
                contexts=contexts,
                desired_conviction=desired_conviction,
                preserve_feature_values=preserve_feature_values,
                trainee_id=trainee_id
            )
        )

        if action_features is not None and derived_action_features is not None:
            if not set(derived_action_features).issubset(set(action_features)):
                raise ValueError(
                    'Specified \'derived_action_features\' must be a subset of '
                    '\'action_features\'.')

        if new_case_threshold not in [None, "min", "max", "most_similar"]:
            raise ValueError(
                f"The value '{new_case_threshold}' specified for the parameter "
                "`new_case_threshold` is not valid. It accepts one of the"
                " following values - ['min', 'max', 'most_similar',]"
            )

        if desired_conviction is None:
            if contexts is not None:
                for context in contexts:
                    if context is not None and \
                            (len(context) != len(context_features)):
                        raise ValueError(
                            "The number of provided context values in "
                            "`contexts` does not match the number of features "
                            "in `context_features`."
                        )
                total_size = len(contexts)
            else:
                total_size = len(case_indices)

            react_params = {
                "action_values": actions,
                "context_features": context_features,
                "context_values": contexts,
                "action_features": action_features,
                "derived_context_features": derived_context_features,
                "derived_action_features": derived_action_features,
                "case_indices": case_indices,
                "allow_nulls": allow_nulls,
                "input_is_substituted": input_is_substituted,
                "substitute_output": substitute_output,
                "weight_feature": weight_feature,
                "use_case_weights": use_case_weights,
                "leave_case_out": leave_case_out,
                "preserve_feature_values": preserve_feature_values,
                "new_case_threshold": new_case_threshold,
                "details": details,
            }
        else:
            if (
                not isinstance(num_cases_to_generate, int) or
                num_cases_to_generate <= 0
            ):
                raise HowsoError("`num_cases_to_generate` must be an integer "
                                 "greater than 0.")
            total_size = num_cases_to_generate
            if total_size > self._react_generative_batch_threshold:
                # Do not send details for generative reacts over threshold
                details = None

            context_features, contexts = \
                self._preprocess_generate_parameters(
                    trainee_id,
                    action_features=action_features,
                    context_features=context_features,
                    contexts=contexts,
                    desired_conviction=desired_conviction,
                    num_cases_to_generate=num_cases_to_generate,
                    case_indices=case_indices
                )

            react_params = {
                "num_cases_to_generate": num_cases_to_generate,
                "context_features": context_features,
                "context_values": contexts,
                "action_features": action_features,
                "derived_context_features": derived_context_features,
                "derived_action_features": derived_action_features,
                "use_regional_model_residuals": use_regional_model_residuals,
                "desired_conviction": desired_conviction,
                "feature_bounds_map": feature_bounds_map,
                "generate_new_cases": generate_new_cases,
                "ordered_by_specified_features": ordered_by_specified_features,
                "preserve_feature_values": preserve_feature_values,
                "new_case_threshold": new_case_threshold,
                "into_series_store": into_series_store,
                "input_is_substituted": input_is_substituted,
                "substitute_output": substitute_output,
                "weight_feature": weight_feature,
                "use_case_weights": use_case_weights,
                "case_indices": case_indices,
                "leave_case_out": leave_case_out,
                "details": details,
            }

        if self._should_react_batch(react_params, total_size):
            # Run in batch
            if self.verbose:
                print('Batch reacting to context on trainee with id: '
                      f'{trainee_id}')
            response = self._batch_react(trainee_id, react_params,
                                         total_size=total_size,
                                         progress_callback=progress_callback)
        else:
            # Run as a single react request
            if self.verbose:
                print(f'Reacting to context on trainee with id: {trainee_id}')
            with ProgressTimer(total_size) as progress:
                if isinstance(progress_callback, Callable):
                    progress_callback(progress, None)
                response = self._react(trainee_id, react_params)
                progress.update(total_size)

            if isinstance(progress_callback, Callable):
                progress_callback(progress, response)

        response = internals.format_react_response(response)

        if desired_conviction is not None:
            # If the number of cases generated is less then requested, raise
            # warning (only for generative reacts)
            internals.insufficient_generation_check(
                num_cases_to_generate, len(response['action']),
                suppress_warning=suppress_warning
            )

        return response

    def _batch_react(  # noqa: C901
        self,
        trainee_id: str,
        react_params: Dict,
        *,
        total_size: int,
        progress_callback: Optional[Callable] = None
    ):
        """
        Make react requests in batch.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to react to.
        react_params : dict
            The request object.
        total_size : int
            The total size of the data that will be batched.
        progress_callback : callable, optional
            A method to be called during batching to retrieve the progress
            metrics.

        Returns
        -------
        dict
            The react response.
        """
        temp_result = None
        accumulated_result = {'action_values': []}

        actions = react_params.get('action_values')
        contexts = react_params.get('context_values')
        case_indices = react_params.get('case_indices')

        with ProgressTimer(total_size) as progress:
            batch_scaler = self.batch_scaler_class(10, progress)
            gen_batch_size = batch_scaler.gen_batch_size()
            batch_size = next(gen_batch_size, None)

            while not progress.is_complete and batch_size is not None:
                if isinstance(progress_callback, Callable):
                    progress_callback(progress, temp_result)
                batch_start = progress.current_tick
                batch_end = progress.current_tick + batch_size

                if actions is not None and len(actions) > 1:
                    react_params['action_values'] = actions[
                        batch_start:batch_end]
                if contexts is not None and len(contexts) > 1:
                    react_params['context_values'] = contexts[
                        batch_start:batch_end]
                if case_indices is not None and len(case_indices) > 1:
                    react_params['case_indices'] = (
                        case_indices[batch_start:batch_end])

                if react_params.get('desired_conviction') is not None:
                    react_params['num_cases_to_generate'] = batch_size
                temp_result = self._react(trainee_id, react_params)

                internals.accumulate_react_result(accumulated_result,
                                                  temp_result)
                batch_size = next(gen_batch_size, None)

        # Final call to callback on completion
        if isinstance(progress_callback, Callable):
            progress_callback(progress, temp_result)

        return accumulated_result

    def _react(self, trainee_id: str, react_params: Dict) -> Dict:
        """
        Make a single react request.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to react to.
        react_params : dict
            The request parameters.

        Returns
        -------
        dict
            The react response.
        """
        ret = self.howso.batch_react(trainee_id, **react_params)

        # Action values and features should always be defined
        if not ret.get('action_values'):
            ret['action_values'] = []
        if not ret.get('action_features'):
            ret['action_features'] = []

        return ret

    def _should_react_batch(self, react_params: Dict, total_size: int) -> bool:
        """
        Determine if given react should be batched.

        Parameters
        ----------
        react_params : dict
            The react request parameters.
        total_size : int
            The size of the cases being reacted to.

        Returns
        -------
        bool
            Whether a react should be batched.
        """
        if react_params.get('desired_conviction') is not None:
            if total_size > self._react_generative_batch_threshold:
                return True
        else:
            if total_size > self._react_discriminative_batch_threshold:
                return True

        return False

    def _preprocess_react_parameters(
        self,
        *,
        action_features: Iterable[str],
        actions: List[object],
        case_indices: Iterable[Sequence[Union[str, int]]],
        context_features: Iterable[str],
        contexts: List[object],
        desired_conviction: float,
        preserve_feature_values: Iterable[str],
        trainee_id: str
    ) -> Tuple[List[str], List[object], List[str], List[object]]:
        """
        Preprocess parameters for `react_` methods.

        Helper method to pre-process the parameters. Updates the passed-in
        parameters as necessary.

        Parameters
        ----------
        action_features: iterable of str
            See :meth:`HowsoDirectClient.react()`.
        actions: list of object
            See :meth:`HowsoDirectClient.react()`.
        context_features: iterable of str
            See :meth:`HowsoDirectClient.react()`.
        contexts: list of object
            See :meth:`HowsoDirectClient.react()`.
        desired_conviction : float
            See :meth:`HowsoDirectClient.react()`.
        case_indices: iterable of sequence of str, int
            See :meth:`HowsoDirectClient.react()`.
        preserve_feature_values: iterable of str
            See :meth:`HowsoDirectClient.react()`.
        trainee_id : str
            See :meth:`HowsoDirectClient.react()`.

        Returns
        -------
        tuple
           Updated action_features, actions, context_features, contexts
        """
        # Validate case_indices if provided
        if case_indices is not None:
            validate_case_indices(case_indices)

        # Get default features
        self._auto_resolve_trainee(trainee_id)
        trainee = self.trainee_cache.get(trainee_id)

        # Preprocess contexts
        if contexts is not None and context_features is None:
            context_features = internals.get_features_from_data(
                contexts,
                default_features=trainee.default_context_features,
                data_parameter='contexts',
                features_parameter='context_features')
        contexts = serialize_cases(
            contexts, context_features or trainee.default_context_features,
            trainee.features)

        # Preprocess actions
        if actions is not None and action_features is None:
            validate_list_shape(actions, 2, "actions", "object")
            action_features = internals.get_features_from_data(
                actions,
                default_features=trainee.default_action_features,
                data_parameter='actions',
                features_parameter='action_features')
        actions = serialize_cases(
            actions, action_features or trainee.default_action_features,
            trainee.features)

        # validate discriminative-react only parameters
        if desired_conviction is None:
            validate_list_shape(contexts, 2, "contexts", "list of object")
            validate_list_shape(action_features, 1, "action_features", "str")
            validate_list_shape(context_features, 1, "context_features", "str")

            if self.verbose:
                print(f'Reacting to context on trainee with id: {trainee_id}')

            if action_features is None:
                action_features = trainee.default_action_features

            if context_features is None and preserve_feature_values is None:
                context_features = trainee.default_context_features

            if contexts is None:
                if case_indices is None or preserve_feature_values is None:
                    raise ValueError(
                        "If `contexts` are not specified, both `case_indices`"
                        " and `preserve_feature_values` must be specified."
                    )

        return action_features, actions, context_features, contexts

    def react_into_features(
        self,
        trainee_id: str,
        *,
        features: Optional[Iterable[str]] = None,
        familiarity_conviction_addition: Optional[Union[str, bool]] = False,
        familiarity_conviction_removal: Optional[Union[str, bool]] = False,
        p_value_of_addition: Optional[Union[str, bool]] = False,
        p_value_of_removal: Optional[Union[str, bool]] = False,
        similarity_conviction: Optional[Union[str, bool]] = False,
        distance_contribution: Optional[Union[str, bool]] = False,
        weight_feature: Optional[str] = None,
        use_case_weights: bool = False
    ):
        """
        Calculate and cache conviction and other statistics.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to calculate and store conviction for.
        features : iterable of str, optional
            An iterable of features to calculate convictions.
        familiarity_conviction_addition : bool or str, default False
            The name of the feature to store conviction of addition
            values. If set to True the values will be stored to the feature
            'familiarity_conviction_addition'.
        familiarity_conviction_removal : bool or str, default False
            The name of the feature to store conviction of removal
            values. If set to True the values will be stored to the feature
            'familiarity_conviction_removal'.
        p_value_of_addition : bool or str, default False
            The name of the feature to store p value of addition
            values. If set to True the values will be stored to the feature
            'p_value_of_addition'.
        p_value_of_removal : bool or str, default False
            The name of the feature to store p value of removal
            values. If set to True the values will be stored to the feature
            'p_value_of_removal'.
        similarity_conviction : bool or str, default False
            The name of the feature to store similarity conviction
            values. If set to True the values will be stored to the feature
            'similarity_conviction'.
        distance_contribution : bool or str, default False
            The name of the feature to store distance contribution.
            If set to True the values will be stored to the
            feature 'distance_contribution'.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.
        use_case_weights : bool, default False
            If set to True will scale influence weights by each
            case's weight_feature weight.
        """
        self._auto_resolve_trainee(trainee_id)
        validate_list_shape(features, 1, "features", "str")
        if self.verbose:
            print(f'Reacting into features on trainee with id: {trainee_id}')
        self.howso.react_into_features(
            trainee_id,
            features=features,
            familiarity_conviction_addition=familiarity_conviction_addition,
            familiarity_conviction_removal=familiarity_conviction_removal,
            p_value_of_addition=p_value_of_addition,
            p_value_of_removal=p_value_of_removal,
            similarity_conviction=similarity_conviction,
            distance_contribution=distance_contribution,
            weight_feature=weight_feature,
            use_case_weights=use_case_weights)
        self._auto_persist_trainee(trainee_id)

    def get_cases(
        self,
        trainee_id: str,
        session: Optional[str] = None,
        case_indices: Optional[Iterable[Sequence[Union[str, int]]]] = None,
        indicate_imputed: bool = False,
        features: Optional[Iterable[str]] = None,
        condition: Optional[Dict] = None,
        num_cases: Optional[int] = None,
        precision: Optional[Literal["exact", "similar"]] = None
    ) -> client_models.Cases:
        """
        Retrieve cases from a model given a trainee id.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee retrieve cases from.

        session : str, optional
            The session ID to retrieve cases for, in their trained order.

            NOTE: If a session is not provided, retrieves all feature values
                  for cases for all (unordered) sessions in the order they
                  were trained within each session.

        case_indices : iterable of sequence of str, int, optional
            Iterable of Sequences, of session id and index, where index is the
            original 0-based index of the case as it was trained into the
            session. If specified, returns only these cases and ignores the
            session parameter.

        indicate_imputed : bool, default False
            If set, an additional value will be appended to the cases
            indicating if the case was imputed.

        features : iterable of str, optional
            A list of feature names to return values for in leu of all
            default features.

            Built-in features that are available for retrieval:

                | **.session** - The session id the case was trained under.
                | **.session_training_index** - 0-based original index of the
                  case, ordered by training during the session; is never
                  changed.

        condition : dict, optional
            The condition map to select the cases to retrieve that meet all the
            provided conditions.

            .. NOTE::
                The dictionary keys are the feature name and values are one of:

                    - None
                    - A value, must match exactly.
                    - An array of two numeric values, specifying an inclusive
                      range. Only applicable to continuous and numeric ordinal
                      features.
                    - An array of string values, must match any of these values
                      exactly. Only applicable to nominal and string ordinal
                      features.

            .. TIP::
                Example 1 - Retrieve all values belonging to `feature_name`::

                    criteria = {"feature_name": None}

                Example 2 - Retrieve cases that have the value 10::

                    criteria = {"feature_name": 10}

                Example 3 - Retrieve cases that have a value in range [10, 20]::

                    criteria = {"feature_name": [10, 20]}

                Example 4 - Retrieve cases that match one of ['a', 'c', 'e']::

                    condition = {"feature_name": ['a', 'c', 'e']}

                Example 5 - Retrieve cases using session name and index::

                    criteria = {'.session':'your_session_name',
                                '.session_training_index': 1}

        num_cases : int, default None
            The maximum amount of cases to retrieve. If not specified, the limit
            will be k cases if precision is "similar", or no limit if precision
            is "exact".

        precision : {"exact", "similar}, optional
            The precision to use when retrieving the cases via condition.
            Options are "exact" or "similar". If not provided, "exact" will
            be used.

        Returns
        -------
        howso.openapi.models.Cases
            A cases object containing the feature names and cases.
        """
        # Validate case_indices if provided
        if case_indices is not None:
            validate_case_indices(case_indices)

        if isinstance(precision, str):
            if precision not in self.SUPPORTED_PRECISION_VALUES:
                warnings.warn(self.INCORRECT_PRECISION_VALUE_WARNING)

        self._auto_resolve_trainee(trainee_id)
        validate_list_shape(features, 1, "features", "str")
        if session is None and case_indices is None:
            warnings.warn("Calling get_cases without session id does not "
                          "guarantee case order.")
        if self.verbose:
            print('Retrieving cases.')
        result = self.howso.get_cases(
            trainee_id,
            features=features,
            session=session,
            case_indices=case_indices,
            indicate_imputed=1 if indicate_imputed else 0,
            condition=condition,
            num_cases=num_cases,
            precision=precision
        )
        if result is None:
            result = dict()
        return client_models.Cases(features=result.get('features'),
                                   cases=result.get('cases'))

    def react_group(
        self,
        trainee_id: str,
        *,
        new_cases: Optional[Union[List[List[List[object]]], List[DataFrame]]] = None,
        features: Optional[Iterable[str]] = None,
        trainees_to_compare: Optional[Iterable[str]] = None,
        distance_contributions: bool = False,
        familiarity_conviction_addition: bool = True,
        familiarity_conviction_removal: bool = False,
        kl_divergence_addition: bool = False,
        kl_divergence_removal: bool = False,
        p_value_of_addition: bool = False,
        p_value_of_removal: bool = False,
        weight_feature: Optional[str] = None,
        use_case_weights: bool = False
    ) -> client_models.ReactGroupResponse:
        """
        Computes specified data for a **set** of cases.

        Return the list of familiarity convictions (and optionally, distance
        contributions or p values) for each set.

        Parameters
        ----------
        trainee_id : str
            The trainee id.

        new_cases : list of list of list of object or list of DataFrame, optional
            Specify a **set** using a list of cases to compute the conviction of
            groups of cases as shown in the following example.

            >>> [ [[1, 2, 3], [4, 5, 6], [7, 8, 9]], # Group 1
            >>>   [[1, 2, 3]] ] # Group 2

        features : iterable of str, optional
            An iterable of feature names to consider while calculating
            convictions.
        trainees_to_compare : iterable of str, optional
            If specified ignores the 'new_cases' parameter and uses
            cases from this other specified trainee instead.
        distance_contributions : bool, default False
            Calculate and output distance contribution ratios in
            the output dict for each case.
        familiarity_conviction_addition : bool, default True
            Calculate and output familiarity conviction of adding the
            specified cases.
        familiarity_conviction_removal : bool, default False
            Calculate and output familiarity conviction of removing
            the specified cases.s
        kl_divergence_addition : bool, default False
            Calculate and output KL divergence of adding the
            specified cases.
        kl_divergence_removal : bool, default False
            Calculate and output KL divergence of removing the
            specified cases.
        p_value_of_addition : bool, default False
            If true will output p value of addition.
        p_value_of_removal : bool, default False
            If true will output p value of removal.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.
        use_case_weights : bool, default False
            If set to True will scale influence weights by each
            case's weight_feature weight.

        Returns
        -------
        dict
            The react response.
        """
        self._auto_resolve_trainee(trainee_id)
        feature_attributes = self.trainee_cache.get(trainee_id).features
        serialized_cases = None

        if new_cases is not None:
            # if trainees_to_compare is specified, ignore new_cases
            if trainees_to_compare is not None:
                new_cases = None
            elif num_list_dimensions(new_cases) != 3:
                raise ValueError(
                    "Improper shape of `new_cases` values passed. "
                    "`new_cases` must be a 3d list of object.")
        elif trainees_to_compare is None:
            raise ValueError(
                "Either `new_cases` or `trainees_to_compare` must be provided.")

        if trainees_to_compare is not None:
            # Ensure all trainees being compared are available
            for other_trainee_id in trainees_to_compare:
                self._auto_resolve_trainee(other_trainee_id)

        if new_cases is not None:
            serialized_cases = []
            for group in new_cases:
                if features is None:
                    features = internals.get_features_from_data(group)
                serialized_cases.append(serialize_cases(group, features,
                                                        feature_attributes))

        if self.verbose:
            print('Reacting to a set of cases on trainee with id: '
                  f'{trainee_id}')

        ret = self.howso.batch_react_group(
            trainee_id,
            features=features,
            new_cases=serialized_cases,
            trainees_to_compare=trainees_to_compare,
            familiarity_conviction_addition=familiarity_conviction_addition,
            familiarity_conviction_removal=familiarity_conviction_removal,
            kl_divergence_addition=kl_divergence_addition,
            kl_divergence_removal=kl_divergence_removal,
            p_value_of_addition=p_value_of_addition,
            p_value_of_removal=p_value_of_removal,
            distance_contributions=distance_contributions,
            weight_feature=weight_feature,
            use_case_weights=use_case_weights
        )

        return ret

    def get_feature_conviction(
        self,
        trainee_id: str,
        *,
        features: Optional[Iterable[str]] = None,
        action_features: Optional[Iterable[str]] = None,
        familiarity_conviction_addition: bool = True,
        familiarity_conviction_removal: bool = False,
        weight_feature: Optional[str] = None,
        use_case_weights: bool = False
    ) -> Dict:
        """
        Get familiarity conviction for features in the model.

        Parameters
        ----------
        trainee_id : str
            The id of the trainee.
        features : iterable of str, optional
            An iterable of feature names to calculate convictions. At least 2
            features are required to get familiarity conviction. If not
            specified all features will be used.
        action_features : iterable of str, optional
            An iterable of feature names to be treated as action features
            during conviction calculation in order to determine the conviction
            of each feature against the set of action_features. If not
            specified, conviction is computed for each feature against the rest
            of the features as a whole.
        familiarity_conviction_addition : bool, default True
            Calculate and output familiarity conviction of adding the
            specified features in the output.
        familiarity_conviction_removal : bool, default False
            Calculate and output familiarity conviction of removing
            the specified features in the output.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.
        use_case_weights : bool, default False
            If set to True will scale influence weights by each
            case's weight_feature weight.

        Returns
        -------
        dict
            A dict with familiarity_conviction_addition or
            familiarity_conviction_removal
        """
        self._auto_resolve_trainee(trainee_id)
        validate_list_shape(features, 1, "features", "str")
        validate_list_shape(action_features, 1, "action_features", "str")
        if self.verbose:
            print('Getting conviction of features for trainee with id: '
                  f'{trainee_id}')
        return self.howso.compute_conviction_of_features(
            trainee_id,
            features=features,
            action_features=action_features,
            familiarity_conviction_addition=familiarity_conviction_addition,
            familiarity_conviction_removal=familiarity_conviction_removal,
            weight_feature=weight_feature,
            use_case_weights=use_case_weights
        )

    def add_feature(
        self,
        trainee_id: str,
        feature: str,
        feature_value: Optional[Union[int, float, str]] = None,
        *,
        condition: Optional[Dict] = None,
        condition_session: Optional[str] = None,
        feature_attributes: Optional[Dict] = None,
        overwrite: bool = False
    ):
        """
        Adds a feature to a trainee.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee add the feature to.
        feature : str
            The name of the feature.
        feature_attributes : dict, optional
            The dict of feature specific attributes for this feature. If
            unspecified and conditions are not specified, will assume feature
            type as 'continuous'.
        feature_value : int or float or str, optional
            The value to populate the feature with.
            By default, populates the new feature with None.
        condition : dict, optional
            A condition map where feature values will only be added when
            certain criteria is met.

            If None, the feature will be added to all cases in the model
            and feature metadata will be updated to include it. If specified
            as an empty dict, the feature will still be added to all cases in
            the model but the feature metadata will not be updated.

            .. NOTE::
                The dictionary keys are the feature name and values are one of:

                    - None
                    - A value, must match exactly.
                    - An array of two numeric values, specifying an inclusive
                      range. Only applicable to continuous and numeric ordinal
                      features.
                    - An array of string values, must match any of these values
                      exactly. Only applicable to nominal and string ordinal
                      features.

            .. TIP::
                For instance to add the `feature_value` only when the
                `length` and `width` features are equal to 10::

                    condition = {"length": 10, "width": 10}

        condition_session : str, optional
            If specified, ignores the condition and operates on cases for the
            specified session id.
        overwrite : bool, default False
            If True, the feature will be over-written if it exists.
        """
        self._auto_resolve_trainee(trainee_id)
        cached_trainee = self.trainee_cache.get(trainee_id)

        if self.verbose:
            print(f'Adding feature {feature}.')

        if feature_attributes is not None:
            updated_attributes = internals.preprocess_feature_attributes(
                {feature: feature_attributes})
            feature_attributes = updated_attributes[feature]

        self.howso.add_feature(
            trainee_id, feature,
            feature_value=feature_value,
            condition=condition,
            condition_session=condition_session,
            feature_attributes=feature_attributes,
            overwrite=overwrite,
            session=self.active_session.id
        )
        self._auto_persist_trainee(trainee_id)

        # Update trainee in cache
        updated_feature_attributes = self.get_feature_attributes(trainee_id)
        cached_trainee.features = updated_feature_attributes

    def remove_feature(
        self,
        trainee_id: str,
        feature: str,
        *,
        condition: Optional[Dict] = None,
        condition_session: Optional[str] = None
    ):
        """
        Removes a feature from a trainee.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee remove the feature from.
        feature : str
            The name of the feature to remove.
        condition : dict, optional
            A condition map where features will only be removed when certain
            criteria is met.

            If None, the feature will be removed from all cases in the model
            and feature metadata will be updated to exclude it. If specified
            as an empty dict, the feature will still be removed from all cases
            in the model but the feature metadata will not be updated.

            .. NOTE::
                The dictionary keys are the feature name and values are one of:

                    - None
                    - A value, must match exactly.
                    - An array of two numeric values, specifying an inclusive
                      range. Only applicable to continuous and numeric ordinal
                      features.
                    - An array of string values, must match any of these values
                      exactly. Only applicable to nominal and string ordinal
                      features.

            .. TIP::
                For instance to remove the `length` feature only when the
                value is between 1 and 5::

                    condition = {"length": [1, 5]}

        condition_session : str, optional
            If specified, ignores the condition and operates on cases for the
            specified session id.
        """
        self._auto_resolve_trainee(trainee_id)
        cached_trainee = self.trainee_cache.get(trainee_id)

        if self.verbose:
            print(f'Removing feature "{feature}" for trainee with '
                  f'id: {trainee_id}')

        self.howso.remove_feature(
            trainee_id, feature,
            condition=condition,
            condition_session=condition_session,
            session=self.active_session.id
        )
        self._auto_persist_trainee(trainee_id)

        # Update trainee in cache
        updated_feature_attributes = self.get_feature_attributes(trainee_id)
        cached_trainee.features = updated_feature_attributes

    def get_feature_residuals(
        self,
        trainee_id: str,
        *,
        action_feature: Optional[str] = None,
        robust: Optional[bool] = None,
        robust_hyperparameters: Optional[bool] = None,
        weight_feature: Optional[str] = None,
    ) -> Dict[str, float]:
        """
        Get cached feature residuals.

        All keyword arguments are optional, when not specified will auto-select
        cached residuals for output, when specified will attempt to
        output the cached residuals best matching the requested parameters,
        if no cached match is found.

        .. deprecated:: 1.0.0
            Use :meth:`HowsoDirectClient.get_prediction_stats` instead.

        Parameters
        ----------
        trainee_id : str
            The id or name of the trainee.
        action_feature : str, optional
            When specified, will attempt to return residuals that
            were computed for this specified action_feature.
            Note: ".targetless" is the action feature used during targetless
            analysis.
        robust : bool, optional
            When specified, will attempt to return residuals that
            were computed with the specified robust or non-robust type.
        robust_hyperparameters : bool, optional
            When specified, will attempt to return residuals that were
            computed using hyperpparameters with the specified robust or
            non-robust type.
        weight_feature : str, optional
            When specified, will attempt to return residuals that
            were computed using this weight_feature.

        Returns
        -------
        dict of str to float
            A map of feature names to residual values.
        """
        warnings.warn(
            'The method `get_feature_residuals()` is deprecated and will be'
            'removed in a future release. Please use `get_prediction_stats()` '
            'instead.', DeprecationWarning)
        self._auto_resolve_trainee(trainee_id)

        if self.verbose:
            print('Getting feature residuals for trainee with '
                  f'id: {trainee_id}')

        residuals = self.howso.get_feature_residuals(
            trainee_id,
            action_feature=action_feature,
            robust=robust,
            robust_hyperparameters=robust_hyperparameters,
            weight_feature=weight_feature)
        return residuals

    def get_feature_mda(
        self,
        trainee_id: str,
        action_feature: str,
        *,
        permutation: Optional[bool] = None,
        robust: Optional[bool] = None,
        weight_feature: Optional[str] = None,
    ) -> Dict[str, float]:
        """
        Get cached feature Mean Decrease In Accuracy (MDA).

        All keyword arguments are optional, when not specified will auto-select
        cached MDA for output, when specified will attempt to
        output the cached MDA best matching the requested parameters,
        if no cached match is found.

        .. deprecated:: 1.0.0
            Use :meth:`HowsoDirectClient.get_prediction_stats` instead.

        Parameters
        ----------
        trainee_id : str
            The id or name of the trainee.
        action_feature : str
            Will attempt to return MDA that was
            computed for this specified action_feature.
        permutation : bool, optional
            When False, will attempt to return MDA that was computed
            by dropping each feature. When True will attempt to return MDA that
            was computed with permutations by scrambling each feature.
        robust : bool, optional
            When specified, will attempt to return MDA that was
            computed with the specified robust or non-robust type.
        weight_feature : str, optional
            When specified, will attempt to return MDA that was
            computed using this weight_feature.

        Returns
        -------
        dict of str to float
            A map of feature names to MDA values.
        """
        warnings.warn(
            'The method `get_feature_mda()` is deprecated and will be'
            'removed in a future release. Please use `get_prediction_stats()` '
            'instead.', DeprecationWarning)
        self._auto_resolve_trainee(trainee_id)

        if self.verbose:
            print(f'Getting mean decrease in accuracy for trainee with id: '
                  f'{trainee_id}')

        mda = self.howso.get_feature_mda(
            trainee_id,
            action_feature=action_feature,
            permutation=permutation,
            robust=robust,
            weight_feature=weight_feature)
        return mda

    def get_feature_contributions(
        self,
        trainee_id: str,
        action_feature: str,
        *,
        robust: Optional[bool] = None,
        directional: bool = False,
        weight_feature: Optional[str] = None,
    ) -> Dict[str, float]:
        """
        Get cached feature contributions.

        All keyword arguments are optional. When not specified, will
        auto-select cached contributions for output. When specified, will
        attempt to output the cached contributions best matching the requested
        parameters, if no cached match is found.

        .. deprecated:: 1.0.0
            Use :meth:`HowsoDirectClient.get_prediction_stats` instead.

        Parameters
        ----------
        trainee_id : str
            The id or name of the trainee.
        action_feature : str
            Will attempt to return contributions that were
            computed for this specified action_feature.
        robust : bool, optional
            When specified, will attempt to return contributions that were
            computed with the specified robust or non-robust type.
        directional : bool, default False
            If false returns absolute feature contributions. If true, returns
            directional feature contributions.
        weight_feature : str, optional
            When specified, will attempt to return contributions that were
            computed using this weight_feature.

        Returns
        -------
        dict of str to float
            A map of feature names to contribution values.
        """
        warnings.warn(
            'The method `get_feature_contributions()` is deprecated and will be'
            'removed in a future release. Please use `get_prediction_stats()` '
            'instead.', DeprecationWarning)
        self._auto_resolve_trainee(trainee_id)

        if self.verbose:
            print(f'Getting feature contributions for trainee with id: '
                  f'{trainee_id}')

        contributions = self.howso.get_feature_contributions(
            trainee_id,
            action_feature=action_feature,
            robust=robust,
            directional=directional,
            weight_feature=weight_feature)
        return contributions

    def get_prediction_stats(
        self,
        trainee_id: str,
        *,
        action_feature: Optional[str] = None,
        condition: Optional[Dict[str, Any]] = None,
        num_cases: Optional[int] = None,
        num_robust_influence_samples_per_case=None,
        precision: Optional[Literal["exact", "similar"]] = None,
        robust: Optional[bool] = None,
        robust_hyperparameters: Optional[bool] = None,
        stats: Optional[Iterable[str]] = None,
        weight_feature: Optional[str] = None,
    ) -> Dict[str, Dict[str, float]]:
        """
        Get feature prediction stats.

        Gets cached stats when condition is None.
        If condition is not None, then uses the condition to select cases and
        computes prediction stats for that set of cases.

        All keyword arguments are optional, when not specified will auto-select
        all cached stats for output, when specified will attempt to
        output the cached stats best matching the requested parameters,
        if no cached match is found.

        Parameters
        ----------
        trainee_id : str
            The id or name of the trainee.
        action_feature : str, optional
            When specified, will attempt to return stats that
            were computed for this specified action_feature.
            Note: ".targetless" is the action feature used during targetless
            analysis.

            .. NOTE::
                If get_prediction_stats is being used with time series analysis,
                the action feature for which the prediction statistics information
                is desired must be specified.
        condition : dict or None, optional
            A condition map to select which cases to compute prediction stats
            for.

            .. NOTE::
                The dictionary keys are the feature name and values are one of:

                    - None
                    - A value, must match exactly.
                    - An array of two numeric values, specifying an inclusive
                      range. Only applicable to continuous and numeric ordinal
                      features.
                    - An array of string values, must match any of these values
                      exactly. Only applicable to nominal and string ordinal
                      features.
        num_cases : int, default None
            The maximum amount of cases to use to calculate prediction stats.
            If not specified, the limit will be k cases if precision is
            "similar", or 1000 cases if precision is "exact". Only used if
            `condition` is not None.
        num_robust_influence_samples_per_case : int, optional
            Specifies the number of robust samples to use for each case for
            robust contribution computations.
            Defaults to 300 + 2 * (number of features).
        precision : str, default None
            The precision to use when selecting cases with the condition.
            Options are 'exact' or 'similar'. If not specified "exact" will be
            used. Only used if `condition` is not None.
        robust : bool, optional
            When specified, will attempt to return stats that
            were computed with the specified robust or non-robust type.
        robust_hyperparameters : bool, optional
            When specified, will attempt to return stats that were
            computed using hyperparameters with the specified robust or
            non-robust type.
        stats : iterable of str, optional
            List of stats to output. When unspecified, returns all.
            Allowed values:

                - accuracy : The number of correct predictions divided by the
                  total number of predictions.
                - confusion_matrix : A map of actual feature value to a map of
                  predicted feature value to counts.
                - contribution : Feature contributions to predicted value when
                  each feature is dropped from the model, applies to all
                  features.
                - mae : Mean absolute error. For continuous features, this is
                  calculated as the mean of absolute values of the difference
                  between the actual and predicted values. For nominal features,
                  this is 1 - the average categorical action probability of each case's
                  correct classes. Categorical action probabilities are the probabilities
                  for each class for the action feature.
                - mda : Mean decrease in accuracy when each feature is dropped
                  from the model, applies to all features.
                - mda_permutation : Mean decrease in accuracy that used
                  scrambling of feature values instead of dropping each
                  feature, applies to all features.
                - precision : Precision (positive predictive) value for nominal
                  features only.
                - r2 : The r-squared coefficient of determination, for
                  continuous features only.
                - recall : Recall (sensitivity) value for nominal features only.
                - rmse : Root mean squared error, for continuous features only.
                - spearman_coeff : Spearman's rank correlation coefficient,
                  for continuous features only.

        weight_feature : str, optional
            When specified, will attempt to return stats that
            were computed using this weight_feature.

        Returns
        -------
        dict of str to dict of str to float
            A map of feature to map of stat type to stat values.
        """
        self._auto_resolve_trainee(trainee_id)

        if isinstance(precision, str):
            if precision not in self.SUPPORTED_PRECISION_VALUES:
                warnings.warn(self.INCORRECT_PRECISION_VALUE_WARNING)

        if self.verbose:
            print('Getting feature prediction stats for trainee with '
                  f'id: {trainee_id}')

        validate_list_shape(stats, 1, "stats", "str")
        valid_stats = {
            "accuracy", "contribution", "confusion_matrix", "mae", "mda",
            "mda_permutation", "precision", "r2", "recall", "rmse",
            "spearman_coeff",
        }

        if stats is not None and not set(stats).issubset(valid_stats):
            raise ValueError(
                'Invalid prediction stats. The following stats are supported: '
                f'{", ".join(valid_stats)}'
            )

        stats = self.howso.get_prediction_stats(
            trainee_id,
            action_feature=action_feature,
            robust=robust,
            robust_hyperparameters=robust_hyperparameters,
            stats=stats,
            weight_feature=weight_feature,
            condition=condition,
            precision=precision,
            num_cases=num_cases,
            num_robust_influence_samples_per_case=num_robust_influence_samples_per_case,
        )
        return stats

    def get_marginal_stats(
        self,
        trainee_id: str,
        *,
        condition: Optional[Dict[str, Any]] = None,
        num_cases: Optional[int] = None,
        precision: Optional[Literal["exact", "similar"]] = None,
        weight_feature: Optional[str] = None
    ) -> Dict[str, Dict[str, float]]:
        """
        Get marginal stats for all features.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to retrieve marginal stats for.
        condition : dict or None, optional
            A condition map to select which cases to compute marginal stats
            for.

            .. NOTE::
                The dictionary keys are the feature name and values are one of:

                    - None
                    - A value, must match exactly.
                    - An array of two numeric values, specifying an inclusive
                      range. Only applicable to continuous and numeric ordinal
                      features.
                    - An array of string values, must match any of these values
                      exactly. Only applicable to nominal and string ordinal
                      features.
        num_cases : int, default None
            The maximum amount of cases to use to calculate marginal stats.
            If not specified, the limit will be k cases if precision is
            "similar". Only used if `condition` is not None.
        precision : str, default None
            The precision to use when selecting cases with the condition.
            Options are 'exact' or 'similar'. If not specified "exact" will be
            used. Only used if `condition` is not None.
        weight_feature : str, optional
            When specified, will attempt to return stats that were computed
            using this weight_feature.

        Returns
        -------
        dict of str to dict of str to float
            A map of feature names to map of stat type to stat values.
        """
        self._auto_resolve_trainee(trainee_id)

        if isinstance(precision, str):
            if precision not in self.SUPPORTED_PRECISION_VALUES:
                warnings.warn(self.INCORRECT_PRECISION_VALUE_WARNING)

        if self.verbose:
            print('Getting feature marginal stats for trainee with '
                  f'id: {trainee_id}')

        stats = self.howso.get_marginal_stats(
            trainee_id,
            condition=condition,
            num_cases=num_cases,
            precision=precision,
            weight_feature=weight_feature)
        return stats

    def react_into_trainee(
        self,
        trainee_id: str,
        *,
        action_feature: Optional[str] = None,
        context_features: Optional[Iterable[str]] = None,
        contributions: Optional[bool] = None,
        contributions_robust: Optional[bool] = None,
        hyperparameter_param_path: Optional[Iterable[str]] = None,
        mda: Optional[bool] = None,
        mda_permutation: Optional[bool] = None,
        mda_robust: Optional[bool] = None,
        mda_robust_permutation: Optional[bool] = None,
        num_robust_influence_samples: Optional[int] = None,
        num_robust_residual_samples: Optional[int] = None,
        num_robust_influence_samples_per_case: Optional[int] = None,
        num_samples: Optional[int] = None,
        residuals: Optional[bool] = None,
        residuals_robust: Optional[bool] = None,
        sample_model_fraction: Optional[float] = None,
        sub_model_size: Optional[int] = None,
        use_case_weights: bool = False,
        weight_feature: Optional[str] = None
    ):
        """
        Compute and cache specified feature prediction stats.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to react to.
        action_feature : str, optional
            Name of target feature for which to do computations. Default is
            whatever the model was analyzed for, i.e., action feature for MDA
            and contributions, or ".targetless" if analyzed for targetless.
            This parameter is required for MDA or contributions computations.
        context_features : iterable of str, optional
            List of features names to use as contexts for
            computations. Default is all trained non-unique features if
            unspecified.
        contributions: bool, optional
            For each context_feature, use the full set of all other
            context_features to compute the mean absolute delta between
            prediction of action_feature with and without the context_feature
            in the model. False removes cached values.
        contributions_robust: bool, optional
            For each context_feature, use the robust (power set/permutation)
            set of all other context_features to compute the mean absolute
            delta between prediction of action_feature with and without the
            context_feature in the model. False removes cached values.
        hyperparameter_param_path: iterable of str, optional.
            Full path for hyperparameters to use for computation. If specified
            for any residual computations, takes precedence over action_feature
            parameter.  Can be set to a 'paramPath' value from the results of
            'get_params()' for a specific set of hyperparameters.
        mda : bool, optional
            When True will compute Mean Decrease in Accuracy (MDA)
            for each context feature at predicting the action_feature. Drop
            each feature and use the full set of remaining context features
            for each prediction. False removes cached values.
        mda_permutation : bool, optional
            Compute MDA by scrambling each feature and using the
            full set of remaining context features for each prediction.
            False removes cached values.
        mda_robust : bool, optional
            Compute MDA by dropping each feature and using the
            robust (power set/permutations) set of remaining context features
            for each prediction. False removes cached values.
        mda_robust_permutation : bool, optional
            Compute MDA by scrambling each feature and using the
            robust (power set/permutations) set of remaining context features
            for each prediction. False removes cached values.
        num_robust_influence_samples : int, optional
            Total sample size of model to use (using sampling with replacement)
            for robust contribution computation. Defaults to 300.
        num_robust_residual_samples : int, optional
            Total sample size of model to use (using sampling with replacement)
            for robust mda and residual computation.
            Defaults to 1000 * (1 + log(number of features)).  Note: robust mda
            will be updated to use num_robust_influence_samples in a future release.
        num_robust_influence_samples_per_case : int, optional
            Specifies the number of robust samples to use for each case for
            robust contribution computations.
            Defaults to 300 + 2 * (number of features).
        num_samples : int, optional
            Total sample size of model to use (using sampling with replacement)
            for all non-robust computation. Defaults to 1000.
            If specified overrides sample_model_fraction.```
        residuals : bool, optional
            For each context_feature, use the full set of all other
            context_features to predict the feature. When True computes and
            caches MAE (mean absolute error), R^2, RMSE (root mean squared
            error), and Spearman Coefficient for continuous features, and
            MAE, accuracy, precision and recall for nominal features.
            False removes cached values.
        residuals_robust : bool, optional
            For each context_feature, computes and caches the same stats as
            residuals but using the robust (power set/permutations) set of all
            other context_features to predict the feature.
            False removes cached values.
        sample_model_fraction : float, optional
            A value between 0.0 - 1.0, percent of model to use in sampling
            (using sampling without replacement). Applicable only to non-robust
            computation. Ignored if num_samples is specified.
            Higher values provide better accuracy at the cost of compute time.
        sub_model_size : int, optional
            Subset of model to use for calculations. Applicable only
            to models > 1000 cases.
        use_case_weights : bool, default False
            If set to True will scale influence weights by each case's
            weight_feature weight.
        weight_feature : str, optional
            The name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.

        Returns
        -------
        None
        """
        self._auto_resolve_trainee(trainee_id)

        validate_list_shape(context_features, 1, "context_features", "str")

        # if any mda or contributions flags are specified,
        # must specify action_feature
        if [
            contributions,
            contributions_robust,
            mda,
            mda_robust,
            mda_permutation,
            mda_robust_permutation
        ].count(None) != 6:
            if action_feature is None:
                raise ValueError(
                    "Invalid value for `action_feature`, must not be `None`")

        if self.verbose:
            print(f'Reacting into trainee for trainee with id: {trainee_id}')
        self.howso.react_into_trainee(
            trainee_id,
            context_features=context_features,
            use_case_weights=use_case_weights,
            weight_feature=weight_feature,
            num_samples=num_samples,
            residuals=residuals,
            residuals_robust=residuals_robust,
            contributions=contributions,
            contributions_robust=contributions_robust,
            mda=mda,
            mda_permutation=mda_permutation,
            mda_robust=mda_robust,
            mda_robust_permutation=mda_robust_permutation,
            num_robust_influence_samples=num_robust_influence_samples,
            num_robust_residual_samples=num_robust_residual_samples,
            num_robust_influence_samples_per_case=num_robust_influence_samples_per_case,
            hyperparameter_param_path=hyperparameter_param_path,
            sample_model_fraction=sample_model_fraction,
            sub_model_size=sub_model_size,
            action_feature=action_feature)

        self._auto_persist_trainee(trainee_id)

    def get_extreme_cases(
        self,
        trainee_id: str,
        num: int,
        sort_feature: str,
        features: Optional[Iterable[str]] = None
    ) -> client_models.Cases:
        """
        Gets the extreme cases of a trainee for the given feature(s).

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to retrieve extreme cases from.
        num : int
            The number of cases to get.
        sort_feature : str
            The feature name by which extreme cases are sorted by.
        features: iterable of str, optional
            An iterable of feature names to use when getting extreme cases.

        Returns
        -------
        howso.openapi.models.Cases
            A cases object containing the feature names and extreme cases.
        """
        self._auto_resolve_trainee(trainee_id)
        if self.verbose:
            print(f'Getting extreme cases for trainee with id: {trainee_id}')
        result = self.howso.retrieve_extreme_cases_for_feature(
            trainee_id,
            features=features,
            sort_feature=sort_feature,
            num=num)
        if result is None:
            result = dict()
        return client_models.Cases(features=result.get('features'),
                                   cases=result.get('cases'))

    def _preprocess_generate_parameters(  # noqa: C901
        self,
        trainee_id: str,
        *,
        action_features: Optional[Iterable[str]] = None,
        context_features: Optional[Iterable[str]] = None,
        contexts: Optional[List[object]] = None,
        desired_conviction: Optional[float] = None,
        num_cases_to_generate: Optional[int] = None,
        case_indices: Optional[Iterable[Sequence[Union[str, int]]]] = None,
    ) -> Tuple[List[str], List[List[object]]]:
        """
        Helper method to pre-process generative react parameters.

        Updates the passed-in parameters as necessary.

        Parameters
        ----------
        trainee_id : str
            See :meth:`HowsoDirectClient.react`.
        action_features: iterable of str, optionsl
            See :meth:`HowsoDirectClient.react`.
        context_features: iterable of str, optional
            See :meth:`HowsoDirectClient.react`.
        contexts: list of object, optional
            See :meth:`HowsoDirectClient.react`.
        desired_conviction: float, optional
            See :meth:`HowsoDirectClient.react`.
        num_cases_to_generate: int, optional
            See :meth:`HowsoDirectClient.react`.
        case_indices: Iterable of Sequence[Union[str, int]], optional
            See :meth:`HowsoDirectClient.react`.

        Returns
        -------
        tuple
            context_features, context
        """
        if case_indices is not None:
            if len(case_indices) != 1 and \
                    len(case_indices) != num_cases_to_generate:
                raise HowsoError(
                    "The number of `case_indices` provided does not match "
                    "the number of cases to generate."
                )

        # Validate case_indices if provided
        if case_indices is not None:
            validate_case_indices(case_indices)

        validate_list_shape(action_features, 1, "action_features", "str")

        if contexts is not None:
            if len(contexts) != 1 and len(contexts) != num_cases_to_generate:
                raise HowsoError(
                    "The number of case `contexts` provided does not match the "
                    "number of cases to generate."
                )

        if context_features and not contexts:
            raise HowsoError(
                "For generative reacts, when `context_features` are provided, "
                "`contexts` values must also be provided."
            )

        if context_features is not None or contexts is not None:
            if (
                context_features is None
                or contexts is None
                or not isinstance(contexts[0], Sized)
                or len(context_features) != len(contexts[0])
            ):
                raise HowsoError(
                    "The number of provided context values in `contexts` "
                    "does not match the number of features in "
                    "`context_features`."
                )

        if desired_conviction is not None and desired_conviction <= 0:
            raise HowsoError("Desired conviction must be greater than 0.")

        if self.verbose:
            print(f'Generating case from trainee with id: {trainee_id}')

        for i in range(num_cases_to_generate):
            context_values = None
            if contexts is not None:
                if len(contexts) == 1:
                    context_values = contexts[0]
                elif len(contexts) == num_cases_to_generate:
                    context_values = contexts[i]

            if context_features and (
                    not context_values or
                    not isinstance(context_values, Sized) or
                    len(context_values) != len(context_features)
            ):
                raise HowsoError(
                    f"The number of provided context values in `contexts[{i}]` "
                    "does not match the number of features in "
                    "`context_features`."
                )
        return context_features, contexts

    def move_cases(
        self,
        trainee_id: str,
        target_trainee_id: str,
        num_cases: int,
        *,
        case_indices: Optional[Iterable[Tuple[str, int]]] = None,
        condition: Optional[Dict] = None,
        condition_session: Optional[str] = None,
        precision: Optional[Literal["exact", "similar"]] = None,
        preserve_session_data: bool = False
    ) -> int:
        """
        Moves training cases from one trainee to another trainee.

        Parameters
        ----------
        trainee_id : str
            The source trainee to move a cases from.
        target_trainee_id : str
            The target trainee to move the cases to.
        num_cases : int
            The number of cases to move; minimum 1 case must be moved.
            Ignored if case_indices is specified.
        case_indices : list of tuples
            A list of tuples containing session ID and session training index
            for each case to be removed.
        condition : dict, optional
            The condition map to select the cases to move that meet all the
            provided conditions. Ignored if case_indices is specified.

            .. NOTE::
                The dictionary keys are the feature name and values are one of:

                    - None
                    - A value, must match exactly.
                    - An array of two numeric values, specifying an inclusive
                      range. Only applicable to continuous and numeric ordinal
                      features.
                    - An array of string values, must match any of these values
                      exactly. Only applicable to nominal and string ordinal
                      features.

            .. TIP::
                Example 1 - Move all values belonging to `feature_name`::

                    criteria = {"feature_name": None}

                Example 2 - Move cases that have the value 10::

                    criteria = {"feature_name": 10}

                Example 3 - Move cases that have a value in range [10, 20]::

                    criteria = {"feature_name": [10, 20]}

                Example 4 - Remove cases that match one of ['a', 'c', 'e']::

                    condition = {"feature_name": ['a', 'c', 'e']}

                Example 5 - Move cases using session name and index::

                    criteria = {'.session':'your_session_name',
                                '.session_index': 1}

        condition_session : str, optional
            If specified, ignores the condition and operates on cases for
            the specified session id. Ignored if case_indices is specified.
        precision : {"exact", "similar"}, optional
            The precision to use when moving the cases. Options are 'exact'
            or 'similar'. If not specified, "exact" will be used.
            Ignored if case_indices is specified.
        preserve_session_data : bool, default False
            When True, will move cases without cleaning up session data.

        Returns
        -------
        int
            The number of cases moved.
        """
        self._auto_resolve_trainee(trainee_id)
        self._auto_resolve_trainee(target_trainee_id)
        if num_cases < 1:
            raise ValueError('num_cases must be a value greater than 0')

        if isinstance(precision, str):
            if precision not in self.SUPPORTED_PRECISION_VALUES:
                warnings.warn(self.INCORRECT_PRECISION_VALUE_WARNING)

        if self.verbose:
            print(f'Moving case from trainee with id: {trainee_id} to trainee '
                  f'with id: {target_trainee_id}')

        # Convert session instance to id
        if (
            isinstance(condition, dict) and
            isinstance(condition.get('.session'), Session)
        ):
            condition['.session'] = condition['.session'].id

        result = self.howso.move_cases(
            trainee_id, target_trainee_id,
            case_indices=case_indices,
            condition=condition,
            condition_session=condition_session,
            num_cases=num_cases,
            precision=precision,
            preserve_session_data=preserve_session_data,
            session=self.active_session.id
        )
        self._auto_persist_trainee(trainee_id)
        return result.get('count', 0)

    def get_params(
        self,
        trainee_id: str,
        *,
        action_feature: Optional[str] = None,
        context_features: Optional[Iterable[str]] = None,
        mode: Optional[Literal["robust", "full"]] = None,
        weight_feature: Optional[str] = None,
    ) -> Dict:
        """
        Get the parameters used by the Trainee. If 'action_feature',
        'context_features', 'mode', or 'weight_feature' are specified, then
        the best hyperparameters analyzed in the Trainee are the value of the
        'hyperparameter_map' key, otherwise this value will be the dictionary
        containing all the hyperparameter sets in the Trainee.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee get parameters from.
        action_feature : str, optional
            If specified will return the best analyzed hyperparameters to
            target this feature.
        context_features : str, optional
            If specified, will find and return the best analyzed hyperparameters
            to use with these context features.
        mode : str, optional
            If specified, will find and return the best analyzed hyperparameters
            that were computed in this mode.
        weight_feature : str, optional
            If specified, will find and return the best analyzed hyperparameters
            that were analyzed using this weight feaure.

        Returns
        -------
        dict
            A dict including the either all of the Trainee's internal
            parameters or only the best hyperparameters selected using the
            passed parameters.
        """
        self._auto_resolve_trainee(trainee_id)
        if self.verbose:
            print(f'Getting model attributes from trainee with id: '
                  f'{trainee_id}')
        return self.howso.get_internal_parameters(
            trainee_id,
            action_feature=action_feature,
            context_features=context_features,
            mode=mode,
            weight_feature=weight_feature,
        )

    def get_trainee_session_indices(self, trainee_id: str, session: str
                                    ) -> List[int]:
        """
        Get list of all session indices for a specified session.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee get parameters from.
        session : str
            The id of the session to retrieve indices from.

        Returns
        -------
        list of int
            A list of the session indices for the session.
        """
        self._auto_resolve_trainee(trainee_id)
        return self.howso.get_session_indices(trainee_id, session)

    def get_trainee_session_training_indices(
        self,
        trainee_id: str,
        session: str
    ) -> List[int]:
        """
        Get list of all session training indices for a specified session.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee get parameters from.
        session : str
            The id of the session to retrieve indices from.

        Returns
        -------
        list of int
            A list of the session training indices for the session.
        """
        self._auto_resolve_trainee(trainee_id)
        result = self.howso.get_session_training_indices(trainee_id, session)
        if result is None:
            return []
        return result

    def set_params(self, trainee_id: str, params: Dict):
        """
        Sets specific hyperparameters in the trainee.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee set hyperparameters.

        params : dict
            A dictionary in the following format containing the hyperparameter
            information, which is required, and other parameters which are
            all optional.

            Example::

                {
                    "hyperparameter_map": {
                        ".targetless": {
                            "robust": {
                                ".none": {
                                    "dt": -1, "p": .1, "k": 8
                                }
                            }
                        }
                    },
                }
        """
        self._auto_resolve_trainee(trainee_id)
        if self.verbose:
            print(f'Setting model attributes for trainee with id: {trainee_id}')

        deprecated_params = {
            'auto_optimize_enabled': 'auto_analyze_enabled',
            'optimize_threshold': 'analyze_threshold',
            'optimize_growth_factor': 'analyze_growth_factor',
            'auto_optimize_limit_size': 'auto_analyze_limit_size',
        }

        # replace any old params with new params and remove old param
        for old_param, new_param in deprecated_params.items():
            if old_param in params:
                params[new_param] = params[old_param]
                del params[old_param]
                warnings.warn(
                    f'The `{old_param}` parameter has been renamed to '
                    f'`{new_param}`, please use the new parameter '
                    'instead.', UserWarning)

        self.howso.set_internal_parameters(trainee_id, params)
        self._auto_persist_trainee(trainee_id)

    def get_num_training_cases(self, trainee_id: str) -> int:
        """
        Return the number of trained cases in the model.

        Parameters
        ----------
        trainee_id : str
            The Id of the Trainee to retrieve the number of training cases from.

        Returns
        -------
        int
            The number of cases in the model
        """
        self._auto_resolve_trainee(trainee_id)
        ret = self.howso.get_num_training_cases(trainee_id)
        if isinstance(ret, dict):
            return ret.get('count', 0)
        return 0

    def set_auto_analyze_params(  # noqa: C901
        self,
        trainee_id: str,
        auto_analyze_enabled: bool = False,
        analyze_threshold: Optional[int] = None,
        *,
        auto_analyze_limit_size: Optional[int] = None,
        analyze_growth_factor: Optional[float] = None,
        **kwargs
    ):
        """
        Set trainee parameters for auto analysis.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to set auto analysis parameters for.
        auto_analyze_enabled : bool, default False
            When True, the :func:`train` method will trigger an analyze when
            it's time for the model to be analyzed again.
        analyze_threshold : int, optional
            The threshold for the number of cases at which the model should be
            re-analyzed.
        auto_analyze_limit_size : int, optional
            The size of of the model at which to stop doing auto-analysis.
            Value of 0 means no limit.
        analyze_growth_factor : float, optional
            The factor by which to increase the analyze threshold every time
            the model grows to the current threshold size.
        kwargs : dict, optional
            Parameters specific for analyze() may be passed in via kwargs, and
            will be cached and used during future auto-analysis.
        """
        self._auto_resolve_trainee(trainee_id)

        deprecated_params = {
            'auto_optimize_enabled': 'auto_analyze_enabled',
            'optimize_threshold': 'analyze_threshold',
            'optimize_growth_factor': 'analyze_growth_factor',
            'auto_optimize_limit_size': 'auto_analyze_limit_size',
        }
        analyze_deprecated_params = {
            'bypass_hyperparameter_optimization': 'bypass_hyperparameter_analysis',
            'num_optimization_samples': 'num_analysis_samples',
            'optimization_sub_model_size': 'analysis_sub_model_size',
            'optimize_level': 'analyze_level',
            'dwe_values': 'dt_values'
        }

        # explicitly update parameters if old names are provided
        if kwargs:
            for old_param, new_param in deprecated_params.items():
                if old_param in kwargs:
                    if old_param == 'auto_optimize_enabled':
                        auto_analyze_enabled = kwargs[old_param]
                    elif old_param == 'optimize_threshold':
                        analyze_threshold = kwargs[old_param]
                    elif old_param == 'optimize_growth_factor':
                        analyze_growth_factor = kwargs[old_param]
                    elif old_param == 'auto_optimize_limit_size':
                        auto_analyze_limit_size = kwargs[old_param]

                    del kwargs[old_param]
                    warnings.warn(
                        f'The `{old_param}` parameter has been renamed to '
                        f'`{new_param}`, please use the new parameter '
                        'instead.', UserWarning)

            # replace any old kwarg param with new param and remove old param
            for old_param, new_param in analyze_deprecated_params.items():
                if old_param in kwargs:
                    kwargs[new_param] = kwargs[old_param]
                    del kwargs[old_param]
                    warnings.warn(
                        f'The `{old_param}` parameter has been renamed to '
                        f'`{new_param}`, please use the new parameter '
                        'instead.', UserWarning)

        if 'targeted_model' in kwargs:
            targeted_model = kwargs['targeted_model']
            if targeted_model not in ['single_targeted', 'omni_targeted', 'targetless']:
                raise ValueError(
                    f'Invalid value "{targeted_model}" for targeted_model. '
                    'Valid values include single_targeted, omni_targeted, '
                    'and targetless.')

        # Collect valid parameters
        parameters = {}
        for k in dict(kwargs).keys():
            if k in client_models.SetAutoAnalyzeParamsRequest.attribute_map:
                v = kwargs.pop(k)
                if (
                    v is not None or
                    k in client_models.SetAutoAnalyzeParamsRequest.nullable_attributes
                ):
                    parameters[k] = v

        if kwargs:
            warn_params = ', '.join(kwargs)
            warnings.warn(
                f'The following auto analyze parameter(s) "{warn_params}" '
                'are not officially supported by analyze and may or may not '
                'have an effect.', UserWarning)

        self.howso.auto_analyze_params(
            trainee_id=trainee_id,
            auto_analyze_enabled=auto_analyze_enabled,
            analyze_threshold=analyze_threshold,
            auto_analyze_limit_size=auto_analyze_limit_size,
            analyze_growth_factor=analyze_growth_factor,
            **parameters,
            **kwargs
        )
        self._auto_persist_trainee(trainee_id)

    def optimize(self, *args, **kwargs):
        """
        Optimizes a trainee.

        .. deprecated:: 6.0.0
            Use :meth:`HowsoDirectClient.analyze` instead.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee.
        context_features : iterable of str, optional
            The context features to optimize for.
        action_features : iterable of str, optional
            The action features to optimize for.
        k_folds : int
            optional, (default 6) number of cross validation folds to do
        bypass_hyperparameter_optimization : bool
            optional, bypasses hyperparameter optimization
        bypass_calculate_feature_residuals : bool
            optional, bypasses feature residual calculation
        bypass_calculate_feature_weights : bool
            optional, bypasses calculation of feature weights
        use_deviations : bool
            optional, uses deviations for LK metric in queries
        num_samples : int
            used in calculating feature residuals
        k_values : list of int
            optional list used in hyperparameter search
        p_values : list of float
            optional list used in hyperparameter search
        dwe_values : list of float
            optional list used in hyperparameter search
        optimize_level : int
            optional value, if specified, will optimize for the following
            flows:

                1. predictions/accuracy (hyperparameters)
                2. data synth (cache: global residuals)
                3. standard explanations
                4. full analysis
        targeted_model : {"omni_targeted", "single_targeted", "targetless"}
            optional, valid values as follows:

                "single_targeted" = optimize hyperparameters for the
                    specified action_features
                "omni_targeted" = optimize hyperparameters for each context
                    feature as an action feature, ignores action_features
                    parameter
                "targetless" = optimize hyperparameters for all context
                    features as possible action features, ignores
                    action_features parameter
        num_optimization_samples : int, optional
            If the dataset size to too large, optimize on
            (randomly sampled) subset of data. The
            `num_optimization_samples` specifies the number of
            observations to be considered for optimization.
        optimization_sub_model_size : int or Node, optional
            Number of samples to use for optimization. The rest
            will be randomly held-out and not included in calculations.
        inverse_residuals_as_weights : bool, default is False
            When True will compute and use inverse of residuals
            as feature weights
        use_case_weights : bool, default False
            When True will scale influence weights by each
            case's weight_feature weight.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.
        kwargs
            Additional experimental optimize parameters.
        """
        warnings.warn(
            'The method `optimize()` is deprecated and will be'
            'removed in a future release. Please use `analyze()` '
            'instead.', DeprecationWarning)

        self.analyze(*args, **kwargs)

    def auto_optimize(self, trainee_id):
        """
        Auto-optimize the trainee model.

        Re-uses all parameters from the previous optimize or
        set_auto_optimize_params call. If optimize or set_auto_optimize_params
        has not been previously called, auto_optimize will default to a robust
        and versatile optimization.

        .. deprecated:: 6.0.0
            Use :meth:`HowsoDirectClient.auto_analyze` instead.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to auto-optimize.
        """
        warnings.warn(
            'The method `auto_optimize()` is deprecated and will be'
            'removed in a future release. Please use `auto_analyze()` '
            'instead.', DeprecationWarning)

        return self.auto_analyze(trainee_id)

    def set_auto_optimize_params(self, *args, **kwargs):
        """
        Set trainee parameters for auto optimization.

        .. deprecated:: 6.0.0
            Use :meth:`HowsoDirectClient.set_auto_analyze_params` instead.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to set auto optimization parameters for.
        auto_optimize_enabled : bool, default False
            When True, the :func:`train` method will trigger an optimize when
            it's time for the model to be optimized again.
        optimize_threshold : int, optional
            The threshold for the number of cases at which the model should be
            re-optimized.
        auto_optimize_limit_size : int, optional
            The size of of the model at which to stop doing auto-optimization.
            Value of 0 means no limit.
        optimize_growth_factor : float, optional
            The factor by which to increase the optimize threshold every time
            the model grows to the current threshold size.
        kwargs : dict, optional
            Parameters specific for optimize() may be passed in via kwargs, and
            will be cached and used during future auto-optimizations.
        """
        warnings.warn(
            'The method `set_auto_optimize_params()` is deprecated and will be'
            'removed in a future release. Please use `set_auto_analyze_params()` '
            'instead.', DeprecationWarning)

        self.set_auto_analyze_params(*args, **kwargs)

    def auto_analyze(self, trainee_id: str):
        """
        Auto-analyze the trainee model.

        Re-uses all parameters from the previous analyze or
        set_auto_analyze_params call. If analyze or set_auto_analyze_params
        has not been previously called, auto_analyze will default to a robust
        and versatile analysis.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to auto-analyze.
        """
        self._auto_resolve_trainee(trainee_id)
        if self.verbose:
            print(f"Auto-analyzing trainee with id: {trainee_id}")

        self.howso.auto_analyze(trainee_id)
        self._auto_persist_trainee(trainee_id)
        # when debugging output the auto-analyzed parameters into the
        # trace file.
        if self.howso.trace:
            self.howso.get_internal_parameters(trainee_id)

    def get_label(self, entity_id: str, label: str) -> object:
        """
        Get a label value from a Trainee.

        Parameters
        ----------
        entity_id : str
            The ID of the Trainee to get the label from.
        label : str
            The label name to get the value from.

        Returns
        -------
        object
            The value of the label requested.
        """
        if self.verbose:
            print(f'Gets a label from trainee with id: {entity_id}')
        return self.howso.amlg.get_json_from_label(entity_id, label)

    def set_label(self, entity_id: str, label: str, label_value: str):
        """
        Set a label value in the trainee.

        Parameters
        ----------
        entity_id : str
            The ID of the Trainee containing/to contain the label.
        label : str
            The name of the label.
        label_value : object
            The value to set to the label.
        """
        if self.verbose:
            print(f'Setting label for trainee with id: {entity_id}')
        return self.howso.amlg.set_json_to_label(
            entity_id, label, json.dumps(label_value))

    def execute_label(self, entity_id: str, label: str) -> object:
        """
        Execute a label in the trainee.

        Parameters
        ----------
        entity_id : str
            The ID of the Trainee that contains the label to be executed.
        label : str
            The name of the label to execute.

        Returns
        -------
            The raw response from the trainee.
        """
        if self.verbose:
            print(f'Executing label for trainee with id: {entity_id}')
        return self.howso.amlg.execute_entity_json(entity_id, label, "{}")

    def get_pairwise_distances(  # noqa: C901
        self,
        trainee_id: str,
        features: Optional[Iterable[str]] = None,
        *,
        action_feature: Optional[str] = None,
        from_case_indices: Optional[Iterable[Sequence[Union[str, int]]]] = None,
        from_values: Optional[Union[List[List[object]], DataFrame]] = None,
        to_case_indices: Optional[Iterable[Sequence[Union[str, int]]]] = None,
        to_values: Optional[Union[List[List[object]], DataFrame]] = None,
        use_case_weights: bool = False,
        weight_feature: Optional[str] = None
    ) -> List:
        """
        Compute pairwise distances between specified cases.

        Returns a list of computed distances between each respective pair of
        cases specified in either `from_values` or `from_case_indices` to
        `to_values` or `to_case_indices`. If only one case is specified in any
        of the lists, all respective distances are computed to/from that one
        case.

        .. NOTE::
            - One of `from_values` or `from_case_indices` must be specified,
              not both.
            - One of `to_values` or `to_case_indices` must be specified,
              not both.

        Parameters
        ----------
        trainee_id : str
            The trainee ID.
        features : iterable of str, optional
            List of feature names to use when computing pairwise distances.
            If unspecified uses all features.
        action_feature : str, optional
            The action feature. If specified, uses targeted hyperparameters
            used to predict this `action_feature`, otherwise uses targetless
            hyperparameters.
        from_case_indices : Iterable of Sequence[Union[str, int]], optional
            An iterable of sequences, of session id and index, where index
            is the original 0-based index of the case as it was trained into
            the session. If specified must be either length of 1 or match
            length of `to_values` or `to_case_indices`.
        from_values : list of list of object or pandas.DataFrame, optional
            A 2d-list of case values. If specified must be either length of
            1 or match length of `to_values` or `to_case_indices`.
        to_case_indices : Iterable of Sequence[Union[str, int]], optional
            An Iterable of Sequences, of session id and index, where index
            is the original 0-based index of the case as it was trained into
            the session. If specified must be either length of 1 or match
            length of `from_values` or `from_case_indices`.
        to_values : list of list of object or pandas.DataFrame, optional
            A 2d-list of case values. If specified must be either length of
            1 or match length of `from_values` or `from_case_indices`.
        use_case_weights : bool, default False
            If set to True, will scale influence weights by each case's
            `weight_feature` weight.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.

        Returns
        -------
        list
            A list of computed pairwise distances between each corresponding
            pair of cases in `from_case_indices` and `to_case_indices`.
        """
        self._auto_resolve_trainee(trainee_id)
        feature_attributes = self.trainee_cache.get(trainee_id).features

        validate_list_shape(from_values, 2, 'from_values',
                            'list of list of object')
        validate_list_shape(to_values, 2, 'to_values',
                            'list of list of object')

        if from_case_indices is None and from_values is None:
            raise ValueError("One of `from_case_indices` or `from_values` "
                             "must be specified.")
        elif from_case_indices is not None and from_values is not None:
            raise ValueError("Only one of `from_case_indices` or `from_values` "
                             "may be specified, not both.")

        if to_case_indices is None and to_values is None:
            raise ValueError("One of `to_case_indices` or `to_values` "
                             "must be specified.")
        elif to_case_indices is not None and to_values is not None:
            raise ValueError("Only one of `to_case_indices` or `to_values` "
                             "may be specified, not both.")

        # Validate case_indices if provided
        if from_case_indices:
            validate_case_indices(from_case_indices)
        if to_case_indices:
            validate_case_indices(to_case_indices)

        # Serialize values if defined
        if from_values is not None:
            if features is None:
                features = internals.get_features_from_data(
                    from_values, data_parameter='from_values')
            from_values = serialize_cases(from_values, features,
                                          feature_attributes)
        if to_values is not None:
            if features is None:
                features = internals.get_features_from_data(
                    to_values, data_parameter='to_values')
            to_values = serialize_cases(to_values, features, feature_attributes)

        if self.verbose:
            print('Getting pairwise distances for trainee with id: '
                  f'{trainee_id}')

        result = self.howso.pairwise_distances(
            trainee_id,
            features=features,
            action_feature=action_feature,
            from_case_indices=from_case_indices,
            from_values=from_values,
            to_case_indices=to_case_indices,
            to_values=to_values,
            weight_feature=weight_feature,
            use_case_weights=use_case_weights)
        if result is None:
            return []
        return result

    def get_distances(  # noqa: C901
        self,
        trainee_id: str,
        features: Optional[Iterable[str]] = None,
        *,
        action_feature: Optional[str] = None,
        case_indices: Optional[Iterable[Sequence[Union[str, int]]]] = None,
        feature_values: Optional[Union[List[object], DataFrame]] = None,
        use_case_weights: bool = False,
        weight_feature: Optional[str] = None
    ) -> Dict:
        """
        Compute distances matrix for specified cases.

        Returns a dict with computed distances between all cases
        specified in `case_indices` or from all cases in local model as defined
        by `feature_values`. If neither `case_indices` nor `feature_values` is
        specified, returns computed distances for the entire dataset.

        Parameters
        ----------
        trainee_id : str
            The trainee ID.
        features : iterable of str, optional
            List of feature names to use when computing distances. If
            unspecified uses all features.
        action_feature : str, optional
            The action feature. If specified, uses targeted hyperparameters
            used to predict this `action_feature`, otherwise uses targetless
            hyperparameters.
        case_indices : Iterable of Sequence[Union[str, int]], optional
            An Iterable of Sequences, of session id and index, where index is
            the original 0-based index of the case as it was trained into the
            session. If specified, returns distances for all of these
            cases. Ignored if `feature_values` is provided. If neither
            `feature_values` nor `case_indices` is specified, uses full dataset.
        feature_values : list of object or DataFrame, optional
            If specified, returns distances of the local model relative to
            these values, ignores `case_indices` parameter. If provided a
            DataFrame, only the first row will be used.
        use_case_weights : bool, default False
            If set to True, will scale influence weights by each case's
            `weight_feature` weight.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.

        Returns
        -------
        dict
            A dict containing a matrix of computed distances and the list of
            corresponding case indices in the following format::

                {
                    'case_indices': [ session-indices ],
                    'distances': [ [ distances ] ]
                }
        """
        self._auto_resolve_trainee(trainee_id)
        feature_attributes = self.trainee_cache.get(trainee_id).features

        # Validate case_indices if provided
        if case_indices is not None:
            validate_case_indices(case_indices)

        if feature_values is not None:
            if (
                isinstance(feature_values, Iterable)
                and len(np.array(feature_values).shape) == 1
                and len(feature_values) > 0
            ):
                # Convert 1d list to 2d list for serialization
                feature_values = [feature_values]

            if features is None:
                features = internals.get_features_from_data(
                    feature_values, data_parameter='feature_values')
            feature_values = serialize_cases(feature_values, features,
                                             feature_attributes)
            if feature_values:
                # Only a single case should be provided
                feature_values = feature_values[0]
            # Ignored when feature_values specified
            case_indices = None

        if case_indices is not None and len(case_indices) < 2:
            raise ValueError("If providing `case_indices`, must provide at "
                             "least 2 cases for computation.")

        if self.verbose:
            print('Getting distances between cases for trainee with id: '
                  f'{trainee_id}')

        preallocate = True  # If matrix should be preallocated in memory
        page_size = 2000
        indices = []
        distances_matrix = []
        total_rows = 0
        total_cols = 0
        mismatch_msg = (
            "Received mismatched distance value pairs. It is likely some "
            "cases were either deleted or trained during the computation of "
            "get_distances. Rerunning this operation may resolve this error."
        )

        if feature_values is not None:
            # When specifying feature values, only distances closest to this
            # case will be returned. The largest matrix size that could be
            # expected is 144x144, so we can request the entire matrix at once.
            # Set num_cases to 1 so we only page once.
            num_cases = 1
            preallocate = False  # won't know the actual size beforehand
        elif case_indices is not None:
            num_cases = len(case_indices)
        else:
            num_cases = self.get_num_training_cases(trainee_id)

        # Preallocate matrix (This will raise a numpy MemoryError if too large)
        if preallocate:
            distances_matrix = np.zeros((num_cases, num_cases), dtype='float64')

        for row_offset in range(0, num_cases, page_size):
            for column_offset in range(0, num_cases, page_size):
                response = self.howso.distances(
                    trainee_id,
                    features=features,
                    action_feature=action_feature,
                    case_indices=case_indices,
                    feature_values=feature_values,
                    weight_feature=weight_feature,
                    use_case_weights=use_case_weights,
                    row_offset=row_offset,
                    row_count=page_size,
                    column_offset=column_offset,
                    column_count=page_size,
                )
                column_case_indices = response['column_case_indices']
                row_case_indices = response['row_case_indices']
                distances = response['distances']

                if preallocate:
                    # Fill in allocated matrix
                    try:
                        distances_matrix[
                            row_offset:row_offset + len(row_case_indices),
                            column_offset:column_offset + len(column_case_indices)
                        ] = distances
                    except ValueError as err:
                        # Unexpected shape when populating array
                        raise HowsoError(mismatch_msg) from err
                else:
                    if column_offset == 0:
                        # Append new rows
                        distances_matrix += distances
                    else:
                        # Extend existing columns
                        try:
                            for i, cols in enumerate(distances):
                                distances_matrix[row_offset + i].extend(cols)
                        except (AttributeError, IndexError):
                            # Unexpected shape when populating array
                            raise HowsoError(mismatch_msg)

                if column_offset == 0:
                    total_rows += len(row_case_indices)
                if row_offset == 0:
                    total_cols += len(column_case_indices)
                    # Collect the axis indices. Both axis will be the same,
                    # so we only need to collect them the first time we page
                    # through the columns when row offset is 0.
                    indices += column_case_indices

        if preallocate:
            if total_cols != num_cases or total_rows != num_cases:
                # Received unexpected number of distances
                raise HowsoError(mismatch_msg)
        else:
            if distances_matrix:
                # Validate matrix shape
                if (
                    total_cols != total_rows or
                    not all(len(r) == total_cols for r in distances_matrix)
                ):
                    raise HowsoError(mismatch_msg)
            # If we didn't preallocate, matrix is a python list, convert it
            distances_matrix = np.array(distances_matrix, dtype='float64')

        return {
            'case_indices': indices,
            'distances': distances_matrix
        }

    def compute_feature_weights(
        self,
        trainee_id: str,
        action_feature: Optional[str] = None,
        context_features: Optional[Iterable[str]] = None,
        robust: bool = False,
        weight_feature: Optional[str] = None,
        use_case_weights: bool = False
    ) -> Dict[str, float]:
        """
        Compute and set feature weights for specified context and action features.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee.
        action_feature : str, optional
            Action feature for which to set the specified feature weights for.
        context_features: iterable of str
            List of context feature names.
        robust : bool, default False.
            When true, the power set/permutations of features are used as
            contexts to calculate the residual for a given feature. When
            false, the full set of features is used to calculate the
            residual for a given feature.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.
        use_case_weights : bool, default False
            If set to True will scale influence weights by each
            case's weight_feature weight.

        Returns
        -------
        dict
            A dictionary of computed context features -> weights
        """
        self._auto_resolve_trainee(trainee_id)
        cached_trainee = self.trainee_cache.get(trainee_id)

        if action_feature is None and cached_trainee.default_action_features:
            action_feature = cached_trainee.default_action_features[0]
        if context_features is None:
            context_features = cached_trainee.default_context_features

        weights = self.howso.compute_feature_weights(
            trainee_id, action_feature, context_features, robust,
            weight_feature, use_case_weights)
        self._auto_persist_trainee(trainee_id)
        return weights

    def set_feature_weights(
        self,
        trainee_id: str,
        feature_weights: Optional[Dict[str, float]] = None,
        action_feature: Optional[str] = None,
        use_feature_weights: bool = True
    ):
        """
        Set the weights for the features in the Trainee.

        If action_feature is not specified, it will set the passed in weights
        as targetless.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee.
        action_feature : str, optional
            Action feature for which to set the specified feature weights for
        feature_weights : dict, optional
            A dictionary of feature names -> weight values.
            Ex {"a", 1.0, "b": 0.1, "c": 0.5, ... , "z": 1.0}
            If not set, the feature weights are cleared in the model
        use_feature_weights : bool, default True
            When set to true, forces the trainee to use the specified feature
            weights
        """
        self._auto_resolve_trainee(trainee_id)
        self.howso.set_feature_weights(
            trainee_id, feature_weights, action_feature, use_feature_weights)
        self._auto_persist_trainee(trainee_id)

    def set_feature_weights_matrix(
        self,
        trainee_id: str,
        feature_weights_matrix: Dict[str, Dict[str, float]],
        use_feature_weights: bool = True
    ):
        """
        Set the feature weights for all the features in the Trainee.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee.
        feature_weights_matrix : dict
            A dictionary of feature names to a dictionary of feature names to
            weight values.
            i.e. {"a" : {"a", 1.0, "b": 0.1, "c": 0.5, ... , "z": 1.0} }
        use_feature_weights : bool, default True
            When set to true, forces the trainee to use the specified feature
            weights.
        """
        self._auto_resolve_trainee(trainee_id)
        self.howso.set_feature_weights_matrix(
            trainee_id, feature_weights_matrix, use_feature_weights)
        self._auto_persist_trainee(trainee_id)

    def get_feature_weights_matrix(
        self,
        trainee_id: str
    ) -> Dict[str, Dict[str, float]]:
        """
        Get the full feature weights matrix.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee.

        Returns
        -------
        dict
            A dictionary of action feature names to dictionary of feature names
            to feature weight.
        """
        self._auto_resolve_trainee(trainee_id)
        return self.howso.get_feature_weights_matrix(trainee_id)

    def get_feature_attributes(self, trainee_id: str) -> Dict[str, Dict]:
        """
        Get stored feature attributes.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee

        Returns
        -------
        dict
            A dictionary of feature name to dictionary of feature attributes.
        """
        self._auto_resolve_trainee(trainee_id)
        if self.verbose:
            print('Getting feature attributes from trainee with '
                  f'id: {trainee_id}')
        feature_attributes = self.howso.get_feature_attributes(trainee_id)
        return internals.postprocess_feature_attributes(feature_attributes)

    def set_feature_attributes(
        self,
        trainee_id: str,
        feature_attributes: Dict[str, Dict],
    ):
        """
        Sets feature attributes for a Trainee.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee.
        feature_attributes : dict of str to dict
            A dict of dicts of feature attributes. Each key is the feature
            'name' and each value is a dict of feature-specific parameters.

            Example::

                {
                    "length": { "type" : "continuous", "decimal_places": 1 },
                    "width": { "type" : "continuous", "significant_digits": 4 },
                    "degrees": { "type" : "continuous", "cycle_length": 360 },
                    "class": { "type" : "nominal" }
                }
        """
        self._auto_resolve_trainee(trainee_id)
        cached_trainee = self.trainee_cache.get(trainee_id)

        if not isinstance(feature_attributes, dict):
            raise ValueError("`feature_attributes` must be a dict")
        if self.verbose:
            print('Setting feature attributes for trainee with '
                  f'id: {trainee_id}')

        fixed_attribs = internals.preprocess_feature_attributes(
            feature_attributes)
        self.howso.set_feature_attributes(trainee_id, fixed_attribs)
        self._auto_persist_trainee(trainee_id)

        updated_feature_attributes = self.howso.get_feature_attributes(trainee_id)
        # Update trainee in cache
        cached_trainee.features = internals.postprocess_feature_attributes(
            updated_feature_attributes)

    def analyze(
        self,
        trainee_id: str,
        context_features: Optional[Iterable[str]] = None,
        action_features: Optional[Iterable[str]] = None,
        *,
        bypass_calculate_feature_residuals: bool = None,
        bypass_calculate_feature_weights: bool = None,
        bypass_hyperparameter_analysis: bool = None,
        dt_values: Optional[List[float]] = None,
        use_case_weights: bool = None,
        inverse_residuals_as_weights: bool = None,
        k_folds: Optional[int] = None,
        k_values: Optional[List[int]] = None,
        num_analysis_samples: Optional[int] = None,
        num_samples: Optional[int] = None,
        analysis_sub_model_size: Optional[int] = None,
        analyze_level: Optional[int] = None,
        p_values: Optional[List[float]] = None,
        targeted_model: Optional[Literal["omni_targeted", "single_targeted", "targetless"]] = None,
        use_deviations: bool = None,
        weight_feature: Optional[str] = None,
        **kwargs
    ):
        """
        Analyzes a trainee.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee.
        context_features : iterable of str, optional
            The context features to analyze for.
        action_features : iterable of str, optional
            The action features to analyze for.
        k_folds : int
            optional, (default 6) number of cross validation folds to do
        bypass_hyperparameter_analysis : bool
            optional, bypasses hyperparameter analysis
        bypass_calculate_feature_residuals : bool
            optional, bypasses feature residual calculation
        bypass_calculate_feature_weights : bool
            optional, bypasses calculation of feature weights
        use_deviations : bool
            optional, uses deviations for LK metric in queries
        num_samples : int
            used in calculating feature residuals
        k_values : list of int
            optional list used in hyperparameter search
        p_values : list of float
            optional list used in hyperparameter search
        dt_values : list of float
            optional list used in hyperparameter search
        analyze_level : int
            optional value, if specified, will analyze for the following
            flows:

                1. predictions/accuracy (hyperparameters)
                2. data synth (cache: global residuals)
                3. standard explanations
                4. full analysis
        targeted_model : {"omni_targeted", "single_targeted", "targetless"}
            optional, valid values as follows:

                "single_targeted" = analyze hyperparameters for the
                    specified action_features
                "omni_targeted" = analyze hyperparameters for each context
                    feature as an action feature, ignores action_features
                    parameter
                "targetless" = analyze hyperparameters for all context
                    features as possible action features, ignores
                    action_features parameter
        num_analysis_samples : int, optional
            If the dataset size to too large, analyze on
            (randomly sampled) subset of data. The
            `num_analysis_samples` specifies the number of
            observations to be considered for analysis.
        analysis_sub_model_size : int or Node, optional
            Number of samples to use for analysis. The rest
            will be randomly held-out and not included in calculations.
        inverse_residuals_as_weights : bool, default is False
            When True will compute and use inverse of residuals
            as feature weights
        use_case_weights : bool, default False
            When True will scale influence weights by each
            case's weight_feature weight.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.
        kwargs
            Additional experimental analyze parameters.
        """
        self._auto_resolve_trainee(trainee_id)
        cached_trainee = self.trainee_cache.get(trainee_id)

        validate_list_shape(context_features, 1, "context_features", "str")
        validate_list_shape(action_features, 1, "action_features", "str")
        validate_list_shape(p_values, 1, "p_values", "int")
        validate_list_shape(k_values, 1, "k_values", "float")
        validate_list_shape(dt_values, 1, "dt_values", "float")

        if targeted_model not in ['single_targeted', 'omni_targeted', 'targetless', None]:
            raise ValueError(
                f'Invalid value "{targeted_model}" for targeted_model. '
                'Valid values include single_targeted, omni_targeted, '
                'and targetless.')

        if action_features is None:
            action_features = cached_trainee.default_action_features
        if context_features is None:
            context_features = cached_trainee.default_context_features

        deprecated_params = {
            'bypass_hyperparameter_optimization': 'bypass_hyperparameter_analysis',
            'num_optimization_samples': 'num_analysis_samples',
            'optimization_sub_model_size': 'analysis_sub_model_size',
            'optimize_level': 'analyze_level',
            'dwe_values': 'dt_values'
        }
        # explicitly update parameters if old names are provided
        if kwargs:
            for old_param, new_param in deprecated_params.items():
                if old_param in kwargs:
                    if old_param == 'bypass_hyperparameter_optimization':
                        bypass_hyperparameter_analysis = kwargs[old_param]
                    elif old_param == 'num_optimization_samples':
                        num_analysis_samples = kwargs[old_param]
                    elif old_param == 'optimization_sub_model_size':
                        analysis_sub_model_size = kwargs[old_param]
                    elif old_param == 'optimize_level':
                        analyze_level = kwargs[old_param]
                    elif old_param == 'dwe_values':
                        dt_values = kwargs[old_param]

                    del kwargs[old_param]
                    warnings.warn(
                        f'The `{old_param}` parameter has been renamed to '
                        f'`{new_param}`, please use the new parameter '
                        'instead.', UserWarning)

        analyze_params = dict(
            action_features=action_features,
            context_features=context_features,
            bypass_calculate_feature_residuals=bypass_calculate_feature_residuals,  # noqa: #E501
            bypass_calculate_feature_weights=bypass_calculate_feature_weights,
            bypass_hyperparameter_analysis=bypass_hyperparameter_analysis,  # noqa: #E501
            dt_values=dt_values,
            use_case_weights=use_case_weights,
            inverse_residuals_as_weights=inverse_residuals_as_weights,
            k_folds=k_folds,
            k_values=k_values,
            num_analysis_samples=num_analysis_samples,
            num_samples=num_samples,
            analysis_sub_model_size=analysis_sub_model_size,
            analyze_level=analyze_level,
            p_values=p_values,
            targeted_model=targeted_model,
            use_deviations=use_deviations,
            weight_feature=weight_feature,
        )

        # Filter out non nullable parameters
        analyze_params = {
            k: v for k, v in analyze_params.items()
            if v is not None or
            k in client_models.AnalyzeRequest.nullable_attributes
        }
        # Add experimental options
        analyze_params.update(kwargs)

        if kwargs:
            warn_params = ', '.join(kwargs)
            warnings.warn(
                f'The following analyze parameter(s) "{warn_params}" are '
                'not officially supported by analyze and may or may not '
                'have an effect.', UserWarning)

        if self.verbose:
            print(f'Analyzing trainee with id: {trainee_id}')
            print(f'Analyzing trainee with parameters: {analyze_params}')

        self.howso.analyze(trainee_id, **analyze_params)
        self._auto_persist_trainee(trainee_id)

    def evaluate(
        self,
        trainee_id: str,
        features_to_code_map: Dict[str, str],
        *,
        aggregation_code: Optional[str] = None
    ) -> Dict[str, Dict[str, object]]:
        """
        Evaluate custom code on feature values of all cases in the trainee.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee.
        features_to_code_map : dict of str to str
            A dictionary with feature name keys and custom Amalgam code string values.

            The custom code can use "#feature_name 0" to reference the value
            of that feature for each case.
        aggregation_code : str, optional
            A string of custom Amalgam code that can access the list of values
            derived form the custom code in features_to_code_map.
            The custom code can use "#feature_name 0" to reference the list of
            values derived from using the custom code in features_to_code_map.

        Returns
        -------
        dict
            A dictionary with keys: 'evaluated' and 'aggregated'

            'evaluated' is a dictionary with feature name
            keys and lists of values derived from the features_to_code_map
            custom code.

            'aggregated' is None if no aggregation_code is given, it otherwise
            holds the output of the custom 'aggregation_code'
        """
        self._auto_resolve_trainee(trainee_id)

        evaluate_params = dict(
            features_to_code_map=features_to_code_map,
            aggregation_code=aggregation_code,
        )

        return self.howso.evaluate(trainee_id, **evaluate_params)

howso/direct/client.py
howsoai/howso-engine-py
import logging
from pathlib import Path
import platform
from typing import (
    Any,
    Dict,
    Iterable,
    List,
    Literal,
    Optional,
    Sequence,
    Tuple,
    Union,
)
import uuid
import warnings

from amalgam.api import Amalgam
from howso.client.exceptions import HowsoError, HowsoWarning
from howso.utilities.internals import sanitize_for_json
import howso.utilities.json_wrapper as json
import six

_logger = logging.getLogger('howso.direct')

# Position under home directory of downloaded amalgam files
core_lib_dirname = ".howso/lib/core/"
amlg_lib_dirname = ".howso/lib/amlg/"


DEFAULT_CORE_PATH = Path(__file__).parent.parent.joinpath("howso-engine")


class HowsoCore:
    """
    Howso Core API.

    This class is used in conjunction with the Amalgam python interface to
    interact with the Howso Core and Amalgam binaries.

    Parameters
    ----------
    handle : str
        Handle for the Howso entity. If none is provided a random 6 digit
        alphanumeric handle will be assigned.
    library_path : str, optional
        Path to Amalgam library.
    gc_interval : int, default 100
        Number of Amalgam operations to perform before forcing garbage collection.
        Lower is better at memory management but compromises performance.
        Higher is better performance but may result in higher memory usage.
    howso_path : str, default `DEFAULT_CORE_PATH`
        Directory path to the Howso caml files.
    trainee_template_path : str, default `DEFAULT_CORE_PATH`
        Directory path to the trainee_template caml files.
    howso_fname : str, default "howso.caml"
        Name of the Howso caml file with extension.
    trainee_template_fname : str, default "trainee_template.caml"
        Name of the trainee template file with extension.
    write_log : str, optional
        Absolute path to write log file.
    print_log : str, optional
        Absolute path to print log file.
    trace: bool, default False
        If true, sets debug flag for amlg operations. This will generate an
        execution trace useful in debugging with the standard name of
        [HANDLE]_execution.trace.
    sbf_datastore_enabled : bool, default True
        If true, sbf tree structures are enabled.
    max_num_threads : int, default 0
        If a multithreaded Amalgam binary is used, sets the maximum number of
        threads to the value specified. If 0, will use the number of visible
        logical cores.
    """

    PRIMITIVE_TYPES = (float, bool, bytes, six.text_type) + six.integer_types

    def __init__(  # noqa: C901
        self,
        handle: Optional[str] = None,
        library_path: Optional[str] = None,
        gc_interval: int = 100,
        howso_path: Path = DEFAULT_CORE_PATH,
        trainee_template_path: Path = DEFAULT_CORE_PATH,
        howso_fname: str = "howso.caml",
        trainee_template_fname: str = "trainee_template.caml",
        write_log: Optional[str] = None,
        print_log: Optional[str] = None,
        trace: bool = False,
        sbf_datastore_enabled: bool = True,
        max_num_threads: int = 0,
        **kwargs
    ):
        self.handle = handle if handle is not None else self.random_handle()
        if kwargs.get("amlg_debug", None) is not None:
            if trace is None:
                trace = kwargs["amlg_debug"]
            _logger.warning(
                'The "amlg_debug" parameter is deprecated use "trace" instead.')

        self.trace = bool(trace)

        if write_log is not None:
            self.write_log = Path(write_log).expanduser()
        else:
            self.write_log = ''

        if print_log is not None:
            self.print_log = Path(print_log).expanduser()
        else:
            self.print_log = ''

        # The parameters to pass to the Amalgam object - compiled here, so that
        # they can be merged with config file params.
        amlg_params = {
            'library_path': library_path,
            'gc_interval': gc_interval,
            'sbf_datastore_enabled': sbf_datastore_enabled,
            'max_num_threads': max_num_threads,
            'trace': self.trace,
            'execution_trace_file': self.handle + "_execution.trace",
        }

        if amalgam_opts := kwargs.get("amalgam", {}):
            # merge parameters from config.yml - favoring the configured params
            if amlg_params_intersection := amlg_params.keys() & amalgam_opts.keys():
                # Warn that there are conflicts
                _logger.warning(
                    "The following parameters from configuration file will "
                    "override the Amalgam parameters set in the code: " +
                    str(amlg_params_intersection)
                )
        amlg_params = {**amlg_params, **(amalgam_opts or {})}

        # Infer the os/arch from the running platform, unless set in config
        operating_system = amlg_params.setdefault(
            'os', platform.system().lower())
        if operating_system == 'windows':
            library_file_extension = "dll"
        elif operating_system == 'darwin':
            library_file_extension = "dylib"
        else:
            library_file_extension = "so"

        # Assemble the library file name - use multithreaded library by default
        library_postfix = amlg_params.get('library_postfix', '-mt')

        # Infer the architecture unless set, and normalize
        architecture = amlg_params.get('arch', platform.machine().lower())
        if architecture in ['x86_64', 'amd64']:
            architecture = 'amd64'
        elif architecture in ['aarch64_be', 'aarch64', 'armv8b', 'armv8l']:
            # see: https://stackoverflow.com/questions/45125516/possible-values-for-uname-m
            architecture = 'arm64'
        elif architecture == 'arm64_8a':
            # TODO 17132: 8a arm arch is a special case and not currently auto
            # selected by this routine. So if the user specifies it, use it as
            # is. Future work will auto select this based on env.
            pass

        # If download set, try and download the specified version using
        # howso-build-artifacts
        elif amlg_params.get('download'):
            # Download amalgam (unless already there) - and get the path
            amalgam_download_dir = self.download_amlg(amlg_params)
            amlg_params['library_path'] = str(Path(
                amalgam_download_dir, 'lib',
                f"amalgam{library_postfix}.{library_file_extension}"
            ))
            _logger.debug(
                'Using downloaded amalgam location: '
                f'{amlg_params["library_path"]}')

        # If version is set, but download not, use the default download location
        elif amlg_version := amlg_params.get('version'):
            versioned_amlg_location = Path(
                Path.home(), amlg_lib_dirname, operating_system,
                architecture, amlg_version, 'lib',
                f"amalgam{library_postfix}.{library_file_extension}"
            )
            if versioned_amlg_location.exists():
                amlg_params['library_path'] = str(versioned_amlg_location)
                _logger.debug(
                    'Using amalgam version located at: '
                    f'{amlg_params["library_path"]}')
            else:
                raise HowsoError(
                    f'No amalgam library found at {versioned_amlg_location}')

        # Using the defaults
        else:
            _logger.debug(
                'Using default amalgam location: '
                f'{amlg_params["library_path"]}')

        # Filter out invalid amlg_params, and instantiate.
        amlg_params = {
            k: v for k, v in amlg_params.items()
            if k in [
                'library_path', 'gc_interval', 'sbf_datastore_enabled',
                'max_num_threads', 'debug', 'trace', 'execution_trace_file',
                'execution_trace_dir', 'library_postfix', 'arch'
            ]
        }
        self.amlg = Amalgam(**amlg_params)

        core_params = kwargs.get('core') or {}

        # If download, then retrieve using howso-build-artifacts
        if core_params.get('download', False):
            self.howso_path = Path(
                self.download_core(core_params)).expanduser()
            self.trainee_template_path = self.howso_path
            self.default_save_path = Path(self.howso_path, 'trainee')

        # If version is set, but download not, use the default download location
        elif version := core_params.get('version'):
            # Set paths, ensuring tailing slash
            self.howso_path = Path(Path.home(), core_lib_dirname, version)
            self.trainee_template_path = self.howso_path
            self.default_save_path = Path(self.howso_path, "trainee")

        # .... otherwise use default locations
        else:
            # Set paths, ensuring tailing slash
            self.howso_path = Path(howso_path).expanduser()
            self.trainee_template_path = Path(
                trainee_template_path).expanduser()
            self.default_save_path = Path(self.howso_path, "trainee")

        # Allow for trainee save directory to be overridden
        if persisted_trainees_dir := core_params.get('persisted_trainees_dir'):
            self.default_save_path = Path(persisted_trainees_dir).expanduser()
            _logger.debug(
                'Trainee save directory has been overridden to '
                f'{self.default_save_path}')
        else:
            # If no specific location provided, use current working directory.
            self.default_save_path = Path.cwd()

        # make save dir if doesn't exist
        if not self.default_save_path.exists():
            self.default_save_path.mkdir(parents=True)
        # make log dir(s) if they do not exist
        if self.write_log and not self.write_log.parent.exists():
            self.write_log.mkdir()
        if self.print_log and not self.print_log.parent.exists():
            self.print_log.mkdir()

        self.howso_fname = howso_fname
        self.trainee_template_fname = trainee_template_fname
        self.ext = trainee_template_fname[trainee_template_fname.rindex('.'):]

        self.howso_fully_qualified_path = Path(
            self.howso_path, self.howso_fname)
        if not self.howso_fully_qualified_path.exists():
            raise HowsoError(
                f'Howso core file {self.howso_fully_qualified_path} '
                'does not exist')
        _logger.debug(
            'Using howso-core location: '
            f'{self.howso_fully_qualified_path}')

        self.trainee_template_fully_qualified_path = Path(
            self.trainee_template_path, self.trainee_template_fname)
        if not self.trainee_template_fully_qualified_path.exists():
            raise HowsoError(
                'Howso core file '
                f'{self.trainee_template_fully_qualified_path} does not exist')
        _logger.debug('Using howso-core trainee template location: '
                      f'{self.trainee_template_fully_qualified_path}')

        if self.handle in self.get_entities():
            self.loaded = True
        else:
            self.loaded = self.amlg.load_entity(
                handle=self.handle,
                amlg_path=str(self.howso_fully_qualified_path),
                write_log=str(self.write_log),
                print_log=str(self.print_log)
            )

    @staticmethod
    def random_handle() -> str:
        """
        Generate a random 6 byte hexadecimal handle.

        Returns
        -------
        str
            A random 6 byte hex.
        """
        try:
            # Use of secrets/uuid must be used instead of the "random" package
            # as they will not be affected by setting random.seed which could
            # cause duplicate handles to be generated.
            import secrets
            return secrets.token_hex(6)
        except (ImportError, NotImplementedError):
            # Fallback to uuid if operating system does not support secrets
            return uuid.uuid4().hex[-12:]

    def __str__(self) -> str:
        """Return a string representation of the HowsoCore object."""
        template = (
            "Trainee template:\t %s%s\n "
            "Howso Path:\t\t %s%s\n "
            "Save Path:\t\t %s\n "
            "Write Log:\t\t %s\n "
            "Print Log:\t\t %s\n "
            "Handle:\t\t\t %s\n %s")
        return template % (
            self.trainee_template_path,
            self.trainee_template_fname,
            self.howso_path,
            self.howso_fname,
            self.default_save_path,
            self.write_log,
            self.print_log,
            self.handle,
            str(self.amlg)
        )

    def version(self) -> str:
        """Return the version of the Howso Core."""
        if self.trainee_template_fname.split('.')[1] == 'amlg':
            version = "9.9.9"
        else:
            version = self._execute("version", {})
        return version

    def get_trainee_version(
        self,
        trainee_id: str,
        trace_version: Optional[str] = None
    ) -> str:
        """
        Return the version of the Trainee Template.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee to get the version of.
        trace_version : str, optional
            A version comment to include in the trace file. Useful to capture
            client and amalgam versions.
        """
        return self._execute("get_trainee_version", {
            "trainee": trainee_id,
            "version": trace_version,
        })

    def create_trainee(self, trainee_id: str) -> Union[Dict, None]:
        """
        Create a Trainee using the Trainee Template.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee to create.

        Returns
        -------
        dict
            A dict containing the name of the trainee that was created.
        """
        fname = self.trainee_template_fname.split('.')
        return self._execute("create_trainee", {
            "trainee": trainee_id,
            "filepath": f"{self.trainee_template_path}/",
            "trainee_template_filename": fname[0],
            "file_extension": fname[1],
        })

    def get_loaded_trainees(self) -> List[str]:
        """
        Get loaded Trainees.

        Returns
        -------
        list of str
            A list of trainee identifiers that are currently loaded.
        """
        return self._execute("get_loaded_trainees", {})

    def get_entities(self) -> List[str]:
        """
        Get loaded entities.

        Returns
        -------
        list of str
            A list of entity identifiers that are currently loaded.
        """
        return self.amlg.get_entities()

    def load(
        self,
        trainee_id: str,
        filename: Optional[str] = None,
        filepath: Optional[str] = None,
    ) -> Union[Dict, None]:
        """
        Load a persisted Trainee from disk.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee to load.
        filename : str, optional
            The filename to load.
        filepath : str, optional
            The path containing the filename to load.

        Returns
        -------
        dict
            A dict containing the name of the trainee that was created.
        """
        filename = trainee_id if filename is None else filename
        filepath = f"{self.default_save_path}/" if filepath is None else filepath
        ret = self._execute("load", {
            "trainee": trainee_id,
            "filename": filename,
            "filepath": filepath,
        })
        return ret

    def persist(
        self,
        trainee_id: str,
        filename: Optional[str] = None,
        filepath: Optional[str] = None
    ) -> None:
        """
        Save a Trainee to disk.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee to save.
        filename : str, optional
            The name of the file to save the Trainee to.
        filepath : str, optional
            The path of the file to save the Trainee to.
        """
        filename = trainee_id if filename is None else filename
        filepath = (
            f"{self.default_save_path}/" if filepath is None else filepath)
        return self._execute("save", {
            "trainee": trainee_id,
            "filename": filename,
            "filepath": filepath,
        })

    def delete(self, trainee_id: str) -> None:
        """
        Delete a Trainee.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee to delete.
        """
        return self._execute("delete", {"trainee": trainee_id})

    def copy(self, trainee_id: str, target_trainee_id: str) -> Dict:
        """
        Copy the contents of one Trainee into another.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee to copy from.
        target_trainee_id : str
            The identifier of the Trainee to copy into.

        Returns
        -------
        dict
            A dict containing the name of the trainee that was created by copy.
        """
        return self._execute("copy", {
            "trainee": trainee_id,
            "target_trainee": target_trainee_id,
        })

    def remove_series_store(self, trainee_id: str, series: Optional[str] = None
                            ) -> None:
        """
        Delete part or all of the series store from a Trainee.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to delete the series store from.
        series : str, optional
            The ID of the series to remove from the series store.
            If None, the entire series store will be deleted.
        """
        return self._execute("remove_series_store", {
            "trainee": trainee_id,
            "series": series,
        })

    def clean_data(
        self,
        trainee_id: Optional[str],
        context_features: Optional[Iterable[str]] = None,
        action_features: Optional[Iterable[str]] = None,
        remove_duplicates: Optional[bool] = None
    ) -> None:
        """
        Cleans up Trainee data.

        Removes unused sessions, cases or actions missing data, etc. If a
        trainee identifier is not specified, it will look to the entity's own
        label of the same name.

        Parameters
        ----------
        trainee_id : str, optional
            The identifier of the Trainee to clean.
        context_features : list of str, optional
            Only remove cases that don't have specified context features.
        action_features : list of str, optional
            Only remove cases that don't have specified action features.
        remove_duplicates : bool, default False
            If true, will remove duplicate cases (cases with identical values).
        """
        return self._execute("clean_data", {
            "trainee": trainee_id,
            "context_features": context_features,
            "action_features": action_features,
            "remove_duplicates": remove_duplicates,
        })

    def set_substitute_feature_values(
        self,
        trainee_id: str,
        substitution_value_map: Union[Dict, None]
    ) -> None:
        """
        Set substitution feature values used in case generation.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        substitution_value_map : dict or None
            A dictionary of feature name to value to substitution value. If the
            map is None, all substitutions will be disabled and cleared.
        """
        return self._execute("set_substitute_feature_values", {
            "trainee": trainee_id,
            "substitution_value_map": substitution_value_map,
        })

    def get_substitute_feature_values(self, trainee_id: str) -> Dict:
        """
        Get substitution feature values used in case generation.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.

        Returns
        -------
        dict
            The dictionary of feature name to value to substitution value.
        """
        return self._execute("get_substitute_feature_values", {
            "trainee": trainee_id
        })

    def set_session_metadata(
        self,
        trainee_id: str,
        session: str,
        metadata: Optional[Dict]
    ) -> None:
        """
        Set the Trainee session metadata.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        session : str
            The identifier of the Trainee session.
        metadata : dict
            The metadata to associate to the session.
        """
        return self._execute("set_session_metadata", {
            "trainee": trainee_id,
            "session": session,
            "metadata": metadata,
        })

    def get_session_metadata(self, trainee_id: str, session: str
                             ) -> Union[Dict, None]:
        """
        Get the Trainee session metadata.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        session : str
            The identifier of the Trainee session.

        Returns
        -------
        dict or None
            The metadata of the session. Or None if no metadata set.
        """
        return self._execute("get_session_metadata", {
            "trainee": trainee_id,
            "session": session,
        })

    def get_sessions(self, trainee_id: str, attributes: Optional[List[str]]
                     ) -> List[Dict]:
        """
        Get list of session names.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        attributes : list of str, optional
            List of attributes to return from the session. The session id is
            always included.

        Returns
        -------
        list of dict
            The list of Trainee sessions.
        """
        return self._execute("get_sessions", {
            "trainee": trainee_id,
            "attributes": attributes,
        })

    def remove_session(self, trainee_id: str, session: str) -> None:
        """
        Remove a Trainee session.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        session : str
            The identifier of the Trainee session.
        """
        return self._execute("remove_session", {
            "trainee": trainee_id,
            "session": session,
        })

    def remove_feature(
        self,
        trainee_id: str,
        feature: str,
        *,
        condition: Optional[Dict] = None,
        condition_session: Optional[str] = None,
        session: Optional[str] = None
    ) -> None:
        """
        Remove a feature.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        feature : str
            The feature name.
        condition : str, optional
            A condition map where features will only be removed when certain
            criteria is met.
        condition_session : str optional
            If specified, ignores the condition parameter and operates on cases
            for the specified session id.
        session : str, optional
            The identifier of the Trainee session to associate the feature
            removal with.
        """
        return self._execute("remove_feature", {
            "trainee": trainee_id,
            "feature": feature,
            "condition": condition,
            "session": session,
            "condition_session": condition_session,
        })

    def add_feature(
        self,
        trainee_id: str,
        feature: str,
        feature_value: Optional[Union[int, float, str]] = None,
        *,
        condition: Optional[Dict] = None,
        condition_session: Optional[str] = None,
        feature_attributes: Optional[Dict] = None,
        overwrite: bool = False,
        session: Optional[str] = None,
    ) -> None:
        """
        Add a feature.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        feature : str
            The feature name.
        feature_value : int or float or str, optional
            The feature value.
        condition : str, optional
            A condition map where features will only be removed when certain
            criteria is met.
        condition_session : str optional
            If specified, ignores the condition parameter and operates on cases
            for the specified session id.
        overwrite : bool, default False
            If True, the feature will be over-written if it exists.
        session : str, optional
            The identifier of the Trainee session to associate the feature
            addition with.
        """
        return self._execute("add_feature", {
            "trainee": trainee_id,
            "feature": feature,
            "feature_value": feature_value,
            "overwrite": overwrite,
            "condition": condition,
            "feature_attributes": feature_attributes,
            "session": session,
            "condition_session": condition_session,
        })

    def get_num_training_cases(self, trainee_id: str) -> Dict:
        """
        Return the number of trained cases in the model.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.

        Returns
        -------
        dict
            A dictionary containing the key "count".
        """
        return self._execute("get_num_training_cases", {"trainee": trainee_id})

    def auto_analyze_params(
        self,
        trainee_id: str,
        auto_analyze_enabled: bool = False,
        analyze_threshold: Optional[int] = None,
        auto_analyze_limit_size: Optional[int] = None,
        analyze_growth_factor: Optional[float] = None,
        **kwargs
    ) -> None:
        """
        Set trainee parameters for auto analysis.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        auto_analyze_enabled : bool, default False
            Enable auto analyze when training. Train will return a status
            indicating when to auto analyze.
        analyze_threshold : int, optional
            The threshold for the number of cases at which the model should be
            re-analyzed.
        auto_analyze_limit_size : int, optional
            The size of of the model at which to stop doing auto-analysis.
            Value of 0 means no limit.
        analyze_growth_factor : float, optional
            The factor by which to increase the analyze threshold every time
            the model grows to the current threshold size.
        kwargs : dict, optional
            Parameters specific for analyze() may be passed in via kwargs, and
            will be cached and used during future auto-analysis.
        """
        params = {
            "trainee": trainee_id,
            "auto_analyze_enabled": auto_analyze_enabled,
            "analyze_threshold": analyze_threshold,
            "analyze_growth_factor": analyze_growth_factor,
            "auto_analyze_limit_size": auto_analyze_limit_size,
        }
        return self._execute("set_auto_analyze_params", {**kwargs, **params})

    def auto_analyze(self, trainee_id: str) -> None:
        """
        Auto-analyze the Trainee model.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        """
        return self._execute("auto_analyze", {"trainee": trainee_id})

    def compute_feature_weights(
        self,
        trainee_id: str,
        action_feature: Optional[str] = None,
        context_features: Optional[Iterable[str]] = None,
        robust: bool = False,
        weight_feature: Optional[str] = None,
        use_case_weights: bool = False
    ) -> Dict:
        """
        Compute feature weights for specified context and action features.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        action_feature : str, optional
            Action feature for which to set the specified feature weights for.
        context_features: iterable of str
            List of context feature names.
        robust : bool, default False.
            When true, the power set/permutations of features are used as
            contexts to calculate the residual for a given feature. When
            false, the full set of features is used to calculate the
            residual for a given feature.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.
        use_case_weights : bool, default False
            If set to True will scale influence weights by each
            case's weight_feature weight.

        Returns
        -------
        dict
            A dictionary of computed context features -> weights.
        """
        return self._execute("compute_feature_weights", {
            "trainee": trainee_id,
            "action_feature": action_feature,
            "context_features": context_features,
            "robust": robust,
            "weight_feature": weight_feature,
            "use_case_weights": use_case_weights,
        })

    def set_feature_weights(
        self,
        trainee_id: str,
        feature_weights: Optional[Dict[str, float]] = None,
        action_feature: Optional[str] = None,
        use_feature_weights: bool = True
    ) -> None:
        """
        Set the weights for the features in the Trainee.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        action_feature : str, optional
            Action feature for which to set the specified feature weights for
        feature_weights : dict, optional
            A dictionary of feature names -> weight values.
            If not set, the feature weights are cleared in the model.
        use_feature_weights : bool, default True
            When set to true, forces the trainee to use the specified feature
            weights.
        """
        return self._execute("set_feature_weights", {
            "trainee": trainee_id,
            "action_feature": action_feature,
            "feature_weights_map": feature_weights,
            "use_feature_weights": use_feature_weights,
        })

    def set_feature_weights_matrix(
        self,
        trainee_id: str,
        feature_weights_matrix: Dict[str, Dict[str, float]],
        use_feature_weights: bool = True
    ) -> None:
        """
        Set the feature weights for all the features in the Trainee.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        feature_weights_matrix : dict
            A dictionary of feature names to a dictionary of feature names to
            weight values.
        use_feature_weights : bool, default True
            When set to true, forces the trainee to use the specified feature
            weights
        """
        return self._execute("set_feature_weights_matrix", {
            "trainee": trainee_id,
            "feature_weights_matrix": feature_weights_matrix,
            "use_feature_weights": use_feature_weights,
        })

    def get_feature_weights_matrix(self, trainee_id: str
                                   ) -> Dict[str, Dict[str, float]]:
        """
        Get the full feature weights matrix.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.

        Returns
        -------
        dict
            A dictionary of action feature names to dictionary of feature names
            to feature weight.
        """
        return self._execute("get_feature_weights_matrix", {
            "trainee": trainee_id
        })

    def clear_conviction_thresholds(self, trainee_id: str) -> None:
        """
        Set the conviction thresholds to null.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        """
        return self._execute("clear_conviction_thresholds", {
            "trainee": trainee_id,
        })

    def set_conviction_lower_threshold(self, trainee_id: str, threshold: float
                                       ) -> None:
        """
        Set the conviction lower threshold.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        threshold : float
            The threshold value.
        """
        return self._execute("set_conviction_lower_threshold", {
            "trainee": trainee_id,
            "conviction_lower_threshold": threshold,
        })

    def set_conviction_upper_threshold(self, trainee_id: str, threshold: float
                                       ) -> None:
        """
        Set the conviction upper threshold.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        threshold : float
            The threshold value.
        """
        return self._execute("set_conviction_upper_threshold", {
            "trainee": trainee_id,
            "conviction_upper_threshold": threshold,
        })

    def set_metadata(self, trainee_id: str, metadata: Union[Dict, None]
                     ) -> None:
        """
        Set trainee metadata.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        metadata : dict or None
            The metadata dictionary.
        """
        return self._execute("set_metadata", {
            "trainee": trainee_id,
            "metadata": metadata,
        })

    def get_metadata(self, trainee_id: str) -> Union[Dict, None]:
        """
        Get trainee metadata.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.

        Returns
        -------
        dict or None
            The metadata dictionary.
        """
        return self._execute("get_metadata", {"trainee": trainee_id})

    def retrieve_extreme_cases_for_feature(
        self,
        trainee_id: str,
        num: int,
        sort_feature: str,
        features: Optional[Iterable[str]] = None
    ) -> Dict:
        """
        Gets the extreme cases of a Trainee.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        num : int
            The number of cases to get.
        sort_feature : str
            The feature name by which extreme cases are sorted by.
        features: iterable of str, optional
            An iterable of feature names to use when getting extreme cases.

        Returns
        -------
        dict
            A dictionary of keys 'cases' and 'features'.
        """
        return self._execute("retrieve_extreme_cases_for_feature", {
            "trainee": trainee_id,
            "features": features,
            "sort_feature": sort_feature,
            "num": num,
        })

    def train(
        self,
        trainee_id: str,
        input_cases: List[List[Any]],
        features: Optional[Iterable[str]] = None,
        *,
        ablatement_params: Optional[Dict[str, List[Any]]] = None,
        accumulate_weight_feature: Optional[str] = None,
        derived_features: Optional[Iterable[str]] = None,
        input_is_substituted: bool = False,
        series: Optional[str] = None,
        session: Optional[str] = None,
        train_weights_only: bool = False,
    ) -> Dict:
        """
        Train one or more cases into a trainee (model).

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        input_cases : list of list of object
            One or more cases to train into the model.
        features : iterable of str, optional
            An iterable of feature names corresponding to the input cases.
        ablatement_params : dict of str to list of object, optional
            Parameters describing how to ablate cases.
        accumulate_weight_feature : str, optional
            Name of feature into which to accumulate neighbors'
            influences as weight for ablated cases. If unspecified, will not
            accumulate weights.
        derived_features: iterable of str, optional
            List of feature names for which values should be derived
            in the specified order.
        input_is_substituted : bool, default False
            if True assumes provided nominal feature values have
            already been substituted.
        series : str, optional
            Name of the series to pull features and case values
            from internal series storage.
        session : str, optional
            The identifier of the Trainee session to associate the cases with.
        train_weights_only : bool, default False
            When true, and accumulate_weight_feature is provided,
            will accumulate all of the cases' neighbor weights instead of
            training the cases into the model.

        Returns
        -------
        dict
            A dictionary containing the trained details.
        """
        return self._execute("train", {
            "trainee": trainee_id,
            "input_cases": input_cases,
            "features": features,
            "derived_features": derived_features,
            "session": session,
            "ablatement_params": ablatement_params,
            "series": series,
            "input_is_substituted": input_is_substituted,
            "accumulate_weight_feature": accumulate_weight_feature,
            "train_weights_only": train_weights_only,
        })

    def impute(
        self,
        trainee_id: str,
        *,
        batch_size: int = 1,
        features: Optional[Iterable[str]] = None,
        features_to_impute: Optional[Iterable[str]] = None,
        session: Optional[str] = None
    ) -> None:
        """
        Impute, or fill in the missing values, for the specified features.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        batch_size : int, default 1
            Larger batch size will increase accuracy and decrease speed.
            Batch size indicates how many rows to fill before recomputing
            conviction.
        features : iterable of str, optional
            An iterable of feature names to use for imputation. If not
            specified, all features will be used imputed.
        features_to_impute : iterable of str, optional
            An iterable of feature names to impute. If not specified, features
            will be used (see above).
        session : str, optional
            The identifier of the Trainee session to associate the edit with.
        """
        return self._execute("impute", {
            "trainee": trainee_id,
            "features": features,
            "features_to_impute": features_to_impute,
            "session": session,
            "batch_size": batch_size,
        })

    def clear_imputed_session(
        self,
        trainee_id: str,
        impute_session: str,
        *,
        session: Optional[str] = None
    ) -> None:
        """
        Clear values that were imputed during a specified session.

        Won't clear values that were manually set by user after the impute.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        impute_session : str,
            The impute session to clear.
        session : str, optional
            The identifier of the Trainee session to associate this edit with.
        """
        return self._execute("clear_imputed_session", {
            "trainee": trainee_id,
            "session": session,
            "impute_session": impute_session,
        })

    def get_cases(
        self,
        trainee_id: str,
        session: Optional[str] = None,
        *,
        case_indices: Optional[Iterable[Sequence[Union[str, int]]]] = None,
        indicate_imputed: bool = False,
        features: Optional[Iterable[str]] = None,
        condition: Optional[Dict] = None,
        num_cases: Optional[int] = None,
        precision: Optional[Literal["exact", "similar"]] = None
    ) -> Dict:
        """
        Retrieve cases from a Trainee.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        session : str, optional
            The session identifier to retrieve cases for, in their trained
            order.
        case_indices : iterable of sequence of str, int, optional
            Iterable of Sequences, of session id and index, where index is the
            original 0-based index of the case as it was trained into the
            session. If specified, returns only these cases and ignores the
            session parameter.
        indicate_imputed : bool, default False
            If set, an additional value will be appended to the cases
            indicating if the case was imputed.
        features : iterable of str, optional
            A list of feature names to return values for in leu of all
            default features.
        condition : dict, optional
            The condition map to select the cases to retrieve that meet all the
            provided conditions.
        num_cases : int, default None
            The maximum amount of cases to retrieve. If not specified, the limit
            will be k cases if precision is "similar", or no limit if precision
            is "exact".
        precision : {"exact", "similar}, optional
            The precision to use when retrieving the cases via condition.
            If not provided, "exact" will be used.

        Returns
        -------
        dict
            A dictionary containing keys 'features' and 'cases'.
        """
        return self._execute("get_cases", {
            "trainee": trainee_id,
            "features": features,
            "session": session,
            "case_indices": case_indices,
            "indicate_imputed": indicate_imputed,
            "condition": condition,
            "num_cases": num_cases,
            "precision": precision,
        })

    def append_to_series_store(
        self,
        trainee_id: str,
        series: str,
        contexts: List[List[Any]],
        *,
        context_features: Optional[Iterable[str]] = None
    ) -> None:
        """
        Append the specified contexts to a series store.

        For use with train series.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee to append to.
        series : str
            The name of the series store to append to.
        contexts : list of list of object
            The list of list of context values to append to the series.
        context_features : iterable of str, optional
            The list of feature names for contexts.
        """
        return self._execute("append_to_series_store", {
            "trainee": trainee_id,
            "context_features": context_features,
            "context_values": contexts,
            "series": series,
        })

    def react(
        self,
        trainee_id: str,
        *,
        action_features: Optional[Iterable[str]] = None,
        action_values: Optional[List[List[object]]] = None,
        allow_nulls: bool = False,
        case_access_count_label: Optional[str] = None,
        case_indices: Optional[Iterable[Sequence[Union[str, int]]]] = None,
        context_features: Optional[Iterable[str]] = None,
        context_values: Optional[List[List[object]]] = None,
        derived_action_features: Optional[Iterable[str]] = None,
        derived_context_features: Optional[Iterable[str]] = None,
        desired_conviction: Optional[float] = None,
        details: Optional[Dict] = None,
        extra_audit_features: Optional[Iterable[str]] = None,
        feature_bounds_map: Optional[Dict] = None,
        generate_new_cases: Literal["always", "attempt", "no"] = "no",
        input_is_substituted: bool = False,
        into_series_store: Optional[str] = None,
        leave_case_out: bool = False,
        new_case_threshold: Literal["max", "min", "most_similar"] = "min",
        ordered_by_specified_features: bool = False,
        preserve_feature_values: Optional[Iterable[str]] = None,
        substitute_output: bool = True,
        use_case_weights: bool = False,
        use_regional_model_residuals: bool = True,
        weight_feature: Optional[str] = None
    ) -> Dict:
        """
        Single case react.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        context_values : list of list of object, optional
            A 2d list of context values to react to.
            If None for discriminative react, it is assumed that `session`
            and `session_id` keys are set in the `details`.
        action_features : iterable of str, optional
            An iterable of feature names to treat as action features during
            react.
        action_values : list of list of object, optional
            One or more action values to use for action features.
            If specified, will only return the specified explanation
            details for the given actions. (Discriminative reacts only)
        allow_nulls : bool, default False
            When true will allow return of null values if there
            are nulls in the local model for the action features, applicable
            only to discriminative reacts.
        context_features : iterable of str, optional
            An iterable of feature names to treat as context features during
            react.
        derived_context_features : iterable of str, optional
            An iterable of feature names whose values should be computed
            from the provided context in the specified order. Must be different
            than context_features.
        derived_action_features : iterable of str, optional
            An iterable of feature names whose values should be computed
            after generation from the generated case prior to output, in the
            specified order. Must be a subset of action_features.
        input_is_substituted : bool, default False
            if True assumes provided categorical (nominal or
            ordinal) feature values have already been substituted.
        substitute_output : bool, default True
            If False, will not substitute categorical feature
            values. Only applicable if a substitution value map has been set.
        details : dict, optional
            If details are specified, the response will
            contain the requested explanation data along with the reaction.
        desired_conviction : float
            If specified will execute a generative react. If not
            specified will executed a discriminative react. Conviction is the
            ratio of expected surprisal to generated surprisal for each
            feature generated, valid values are in the range of zero to
            infinity.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.
        use_case_weights : bool, default False
            If set to True will scale influence weights by each
            case's weight_feature weight.
        case_indices : Iterable of Sequence[Union[str, int]], defaults to None
            An Iterable of Sequences, of session id and index, where
            index is the original 0-based index of the case as it was trained
            into the session. If this case does not exist, discriminative react
            outputs null, generative react ignores it.
        preserve_feature_values : iterable of str
            List of features that will preserve their values from
            the case specified by case_indices, appending and overwriting the
            specified contexts as necessary.  For generative reacts, if
            case_indices isn't specified will preserve feature values of a
            random case.
        leave_case_out : bool, default False
            If set to True and specified along with case_indices,
            each individual react will respectively ignore the corresponding
            case specified by case_indices by leaving it out.
        into_series_store : str, optional
            The name of a series store. If specified, will store an internal
            record of all react contexts for this session and series to be used
            later with train series.
        use_regional_model_residuals : bool
            If false uses model feature residuals, if True
            recalculates regional model residuals.
        feature_bounds_map : dict of dict
            A mapping of feature names to the bounds for the
            feature values to be generated in.
        generate_new_cases : {"always", "attempt", "no"}, default "no"
            How to generate new cases.
        ordered_by_specified_features : bool, default False
            If True order of generated feature values will match
            the order of specified features.
        new_case_threshold : {"min", "max", "most_similar"}, optional
            Distance to determine the privacy cutoff. If None,
            will default to "min".

        Returns
        -------
        dict
            The react result including audit details.
        """
        return self._execute("react", {
            "trainee": trainee_id,
            "context_features": context_features,
            "context_values": context_values,
            "action_features": action_features,
            "action_values": action_values,
            "details": details,
            "derived_action_features": derived_action_features,
            "derived_context_features": derived_context_features,
            "case_access_count_label": case_access_count_label,
            "extra_audit_features": extra_audit_features,
            "case_indices": case_indices,
            "allow_nulls": allow_nulls,
            "input_is_substituted": input_is_substituted,
            "substitute_output": substitute_output,
            "weight_feature": weight_feature,
            "leave_case_out": leave_case_out,
            "use_case_weights": use_case_weights,
            "use_regional_model_residuals": use_regional_model_residuals,
            "desired_conviction": desired_conviction,
            "feature_bounds_map": feature_bounds_map,
            "generate_new_cases": generate_new_cases,
            "ordered_by_specified_features": ordered_by_specified_features,
            "preserve_feature_values": preserve_feature_values,
            "new_case_threshold": new_case_threshold,
            "into_series_store": into_series_store,
        })

    def batch_react(
        self,
        trainee_id: str,
        *,
        action_features: Optional[Iterable[str]] = None,
        action_values: Optional[List[List[object]]] = None,
        allow_nulls: bool = False,
        case_access_count_label: Optional[str] = None,
        case_indices: Optional[Iterable[Sequence[Union[str, int]]]] = None,
        context_features: Optional[Iterable[str]] = None,
        context_values: Optional[List[List[object]]] = None,
        derived_action_features: Optional[Iterable[str]] = None,
        derived_context_features: Optional[Iterable[str]] = None,
        desired_conviction: Optional[float] = None,
        details: Optional[Dict] = None,
        extra_audit_features: Optional[Iterable[str]] = None,
        feature_bounds_map: Optional[Dict] = None,
        generate_new_cases: Literal["always", "attempt", "no"] = "no",
        input_is_substituted: bool = False,
        into_series_store: Optional[str] = None,
        leave_case_out: bool = False,
        new_case_threshold: Literal["max", "min", "most_similar"] = "min",
        num_cases_to_generate: Optional[int] = None,
        ordered_by_specified_features: bool = False,
        preserve_feature_values: Optional[Iterable[str]] = None,
        substitute_output: bool = True,
        use_case_weights: bool = False,
        use_regional_model_residuals: bool = True,
        weight_feature: Optional[str] = None
    ) -> Dict:
        """
        Multiple case react.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        context_values : list of list of object, optional
            A 2d list of context values to react to.
            If None for discriminative react, it is assumed that `session`
            and `session_id` keys are set in the `details`.
        action_features : iterable of str, optional
            An iterable of feature names to treat as action features during
            react.
        action_values : list of list of object, optional
            One or more action values to use for action features.
            If specified, will only return the specified explanation
            details for the given actions. (Discriminative reacts only)
        allow_nulls : bool, default False
            When true will allow return of null values if there
            are nulls in the local model for the action features, applicable
            only to discriminative reacts.
        context_features : iterable of str, optional
            An iterable of feature names to treat as context features during
            react.
        derived_context_features : iterable of str, optional
            An iterable of feature names whose values should be computed
            from the provided context in the specified order. Must be different
            than context_features.
        derived_action_features : iterable of str, optional
            An iterable of feature names whose values should be computed
            after generation from the generated case prior to output, in the
            specified order. Must be a subset of action_features.
        input_is_substituted : bool, default False
            if True assumes provided categorical (nominal or
            ordinal) feature values have already been substituted.
        substitute_output : bool, default True
            If False, will not substitute categorical feature
            values. Only applicable if a substitution value map has been set.
        details : dict, optional
            If details are specified, the response will
            contain the requested explanation data along with the reaction.
        desired_conviction : float
            If specified will execute a generative react. If not
            specified will executed a discriminative react. Conviction is the
            ratio of expected surprisal to generated surprisal for each
            feature generated, valid values are in the range of zero to
            infinity.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.
        use_case_weights : bool, default False
            If set to True will scale influence weights by each
            case's weight_feature weight.
        case_indices : Iterable of Sequence[Union[str, int]], defaults to None
            An Iterable of Sequences, of session id and index, where
            index is the original 0-based index of the case as it was trained
            into the session. If this case does not exist, discriminative react
            outputs null, generative react ignores it.
        preserve_feature_values : iterable of str
            List of features that will preserve their values from
            the case specified by case_indices, appending and overwriting the
            specified contexts as necessary.  For generative reacts, if
            case_indices isn't specified will preserve feature values of a
            random case.
        leave_case_out : bool, default False
            If set to True and specified along with case_indices,
            each individual react will respectively ignore the corresponding
            case specified by case_indices by leaving it out.
        into_series_store : str, optional
            The name of a series store. If specified, will store an internal
            record of all react contexts for this session and series to be used
            later with train series.
        use_regional_model_residuals : bool
            If false uses model feature residuals, if True
            recalculates regional model residuals.
        feature_bounds_map : dict of dict
            A mapping of feature names to the bounds for the
            feature values to be generated in.
        generate_new_cases : {"always", "attempt", "no"}, default "no"
            How to generate new cases.
        ordered_by_specified_features : bool, default False
            If True order of generated feature values will match
            the order of specified features.
        num_cases_to_generate : int, default 1
            The number of cases to generate.
        new_case_threshold : {"min", "max", "most_similar"}, optional
            Distance to determine the privacy cutoff. If None,
            will default to "min".

        Returns
        -------
        dict
            The react result including audit details.
        """
        return self._execute("batch_react", {
            "trainee": trainee_id,
            "context_features": context_features,
            "context_values": context_values,
            "action_features": action_features,
            "action_values": action_values,
            "derived_context_features": derived_context_features,
            "derived_action_features": derived_action_features,
            "details": details,
            "case_access_count_label": case_access_count_label,
            "extra_audit_features": extra_audit_features,
            "case_indices": case_indices,
            "allow_nulls": allow_nulls,
            "input_is_substituted": input_is_substituted,
            "substitute_output": substitute_output,
            "weight_feature": weight_feature,
            "use_case_weights": use_case_weights,
            "leave_case_out": leave_case_out,
            "num_cases_to_generate": num_cases_to_generate,
            "use_regional_model_residuals": use_regional_model_residuals,
            "desired_conviction": desired_conviction,
            "feature_bounds_map": feature_bounds_map,
            "generate_new_cases": generate_new_cases,
            "ordered_by_specified_features": ordered_by_specified_features,
            "preserve_feature_values": preserve_feature_values,
            "new_case_threshold": new_case_threshold,
            "into_series_store": into_series_store,
        })

    def batch_react_series(
        self,
        trainee_id: str,
        *,
        action_features: Optional[Iterable[str]] = None,
        action_values: Optional[List[List[object]]] = None,
        case_access_count_label: Optional[str] = None,
        case_indices: Optional[Iterable[Sequence[Union[str, int]]]] = None,
        context_values: Optional[List[List[object]]] = None,
        context_features: Optional[Iterable[str]] = None,
        continue_series: Optional[bool] = False,
        derived_action_features: Optional[Iterable[str]] = None,
        derived_context_features: Optional[Iterable[str]] = None,
        desired_conviction: Optional[float] = None,
        details: Optional[Dict] = None,
        extra_audit_features: Optional[Iterable[str]] = None,
        feature_bounds_map: Optional[Dict] = None,
        final_time_steps: Optional[Union[List[object], List[List[object]]]] = None,
        generate_new_cases: Literal["always", "attempt", "no"] = "no",
        init_time_steps: Optional[Union[List[object], List[List[object]]]] = None,
        initial_features: Optional[Iterable[str]] = None,
        initial_values: Optional[Union[List[object], List[List[object]]]] = None,
        input_is_substituted: bool = False,
        leave_case_out: bool = False,
        max_series_lengths: Optional[List[int]] = None,
        new_case_threshold: Literal["max", "min", "most_similar"] = "min",
        num_series_to_generate: int = 1,
        ordered_by_specified_features: bool = False,
        output_new_series_ids: bool = True,
        preserve_feature_values: Optional[Iterable[str]] = None,
        series_context_features: Optional[Iterable[str]] = None,
        series_context_values: Optional[Union[List[object], List[List[object]]]] = None,
        series_id_tracking: Literal["dynamic", "fixed", "no"] = "fixed",
        series_stop_maps: Optional[List[Dict[str, Dict]]] = None,
        substitute_output: bool = True,
        use_case_weights: bool = False,
        use_regional_model_residuals: bool = True,
        weight_feature: Optional[str] = None
    ) -> Dict:
        """
        React in a series until a series_stop_map condition is met.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        num_series_to_generate : int, optional
            The number of series to generate.
        final_time_steps : list of object, optional
            The time steps at which to end synthesis. Time-series
            only. Must provide either one for all series, or exactly one per
            series.
        init_time_steps : list of object, optional
            The time steps at which to begin synthesis. Time-series
            only. Must provide either one for all series, or exactly one per
            series.
        initial_features : iterable of str, optional
            List of features to condition just the first case in a
            series, overwrites context_features and derived_context_features
            for that first case. All specified initial features must be in one
            of: context_features, action_features, derived_context_features or
            derived_action_features. If provided a value that isn't in one of
            those lists, it will be ignored.
        initial_values : list of list of object, optional
            2d list of values corresponding to the initial_features,
            used to condition just the first case in each series. Must provide
            either one for all series, or exactly one per series.
        series_stop_maps : list of dict of dict, optional
            A dictionary of feature name to stop conditions. Must provide either
            one for all series, or exactly one per series.
        max_series_lengths : list of int, optional
            maximum size a series is allowed to be.  Default is
            3 * model_size, a 0 or less is no limit. Must provide
            either one for all series, or exactly one per series.
        continue_series : bool, default False
            When True will attempt to continue existing series instead of
            starting new series. If `initial_values` provide series IDs, it
            will continue those explicitly specified IDs, otherwise it will
            randomly select series to continue.
            .. note::

                Terminated series with terminators cannot be continued and
                will result in null output.

        derived_context_features : iterable of str, optional
            List of context features whose values should be computed
            from the entire series in the specified order. Must be
            different than context_features.
        derived_action_features : iterable of str, optional
            List of action features whose values should be computed
            from the resulting last row in series, in the specified order.
            Must be a subset of action_features.
        series_context_features : iterable of str, optional
            List of context features corresponding to
            series_context_values, if specified must not overlap with any
            initial_features or context_features.
        series_context_values : list of list of list of object or list, optional
            3d-list of context values, one for each feature for each
            row for each series. If specified, max_series_lengths are ignored.
        output_new_series_ids : bool, default True
            If True, series ids are replaced with unique values on output.
            If False, will maintain or replace ids with existing trained values,
            but also allows output of series with duplicate existing ids.
        series_id_tracking : {"dynamic", "fixed", "no"}, default "fixed"
            Controls how closely generated series should follow existing series.
        context_values: list of list of object
            See parameter ``contexts`` in :meth:`react`.
        action_features: iterable of str
            See parameter ``action_features`` in :meth:`react`.
        action_values: list of list of object
            See parameter ``actions`` in :meth:`react`.
        context_features: iterable of str
            See parameter ``context_features`` in :meth:`react`.
        input_is_substituted : bool, default False
            See parameter ``input_is_substituted`` in :meth:`react`.
        substitute_output : bool
            See parameter ``substitute_output`` in :meth:`react`.
        details: dict, optional
            See parameter ``details`` in :meth:`react`.
        desired_conviction: float
            See parameter ``desired_conviction`` in :meth:`react`.
        weight_feature : str
            See parameter ``weight_feature`` in :meth:`react`.
        use_case_weights : bool
            See parameter ``use_case_weights`` in :meth:`react`.
        case_indices: iterable of sequence of str, int
            See parameter ``case_indices`` in :meth:`react`.
        preserve_feature_values : iterable of str
            See parameter ``preserve_feature_values`` in :meth:`react`.
        new_case_threshold : str
            See parameter ``new_case_threshold`` in :meth:`react`.
        leave_case_out : bool
            See parameter ``leave_case_out`` in :meth:`react`.
        use_regional_model_residuals : bool
            See parameter ``use_regional_model_residuals`` in :meth:`react`.
        feature_bounds_map: dict of dict
            See parameter ``feature_bounds_map`` in :meth:`react`.
        generate_new_cases : {"always", "attempt", "no"}
            See parameter ``generate_new_cases`` in :meth:`react`.
        ordered_by_specified_features : bool
            See parameter ``ordered_by_specified_features`` in :meth:`react`.

        Returns
        -------
        dict
            A dictionary with keys `action_features` and `series`. Where
            `series` is a 2d list of values (rows of data per series), and
            `action_features` is the list of all action features
            (specified and derived).
        """
        return self._execute("batch_react_series", {
            "trainee": trainee_id,
            "context_features": context_features,
            "context_values": context_values,
            "action_features": action_features,
            "action_values": action_values,
            "final_time_steps": final_time_steps,
            "init_time_steps": init_time_steps,
            "initial_features": initial_features,
            "initial_values": initial_values,
            "series_stop_maps": series_stop_maps,
            "max_series_lengths": max_series_lengths,
            "continue_series": continue_series,
            "derived_context_features": derived_context_features,
            "derived_action_features": derived_action_features,
            "series_context_features": series_context_features,
            "series_context_values": series_context_values,
            "series_id_tracking": series_id_tracking,
            "output_new_series_ids": output_new_series_ids,
            "details": details,
            "case_access_count_label": case_access_count_label,
            "extra_audit_features": extra_audit_features,
            "case_indices": case_indices,
            "input_is_substituted": input_is_substituted,
            "substitute_output": substitute_output,
            "weight_feature": weight_feature,
            "use_case_weights": use_case_weights,
            "leave_case_out": leave_case_out,
            "num_series_to_generate": num_series_to_generate,
            "preserve_feature_values": preserve_feature_values,
            "new_case_threshold": new_case_threshold,
            "use_regional_model_residuals": use_regional_model_residuals,
            "desired_conviction": desired_conviction,
            "feature_bounds_map": feature_bounds_map,
            "generate_new_cases": generate_new_cases,
            "ordered_by_specified_features": ordered_by_specified_features,
        })

    def react_into_features(
        self,
        trainee_id: str,
        *,
        features: Optional[Iterable[str]] = None,
        familiarity_conviction_addition: bool = False,
        familiarity_conviction_removal: bool = False,
        p_value_of_addition: bool = False,
        p_value_of_removal: bool = False,
        similarity_conviction: bool = False,
        distance_contribution: bool = False,
        weight_feature: Optional[str] = None,
        use_case_weights: bool = False
    ) -> None:
        """
        Calculate and cache conviction and other statistics.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        features : iterable of str, optional
            An iterable of features to calculate convictions.
        familiarity_conviction_addition : bool or str, default False
            The name of the feature to store conviction of addition
            values. If set to True the values will be stored to the feature
            'familiarity_conviction_addition'.
        familiarity_conviction_removal : bool or str, default False
            The name of the feature to store conviction of removal
            values. If set to True the values will be stored to the feature
            'familiarity_conviction_removal'.
        p_value_of_addition : bool or str, default False
            The name of the feature to store p value of addition
            values. If set to True the values will be stored to the feature
            'p_value_of_addition'.
        p_value_of_removal : bool or str, default False
            The name of the feature to store p value of removal
            values. If set to True the values will be stored to the feature
            'p_value_of_removal'.
        similarity_conviction : bool or str, default False
            The name of the feature to store similarity conviction
            values. If set to True the values will be stored to the feature
            'similarity_conviction'.
        distance_contribution : bool or str, default False
            The name of the feature to store distance contribution.
            If set to True the values will be stored to the
            feature 'distance_contribution'.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.
        use_case_weights : bool, default False
            If set to True will scale influence weights by each
            case's weight_feature weight.
        """
        return self._execute("react_into_features", {
            "trainee": trainee_id,
            "features": features,
            "familiarity_conviction_addition": familiarity_conviction_addition,
            "familiarity_conviction_removal": familiarity_conviction_removal,
            "p_value_of_addition": p_value_of_addition,
            "p_value_of_removal": p_value_of_removal,
            "similarity_conviction": similarity_conviction,
            "distance_contribution": distance_contribution,
            "weight_feature": weight_feature,
            "use_case_weights": use_case_weights,
        })

    def batch_react_group(
        self,
        trainee_id: str,
        *,
        new_cases: Optional[List[List[List[object]]]] = None,
        features: Optional[Iterable[str]] = None,
        trainees_to_compare: Optional[Iterable[str]] = None,
        distance_contributions: bool = False,
        familiarity_conviction_addition: bool = True,
        familiarity_conviction_removal: bool = False,
        kl_divergence_addition: bool = False,
        kl_divergence_removal: bool = False,
        p_value_of_addition: bool = False,
        p_value_of_removal: bool = False,
        weight_feature: Optional[str] = None,
        use_case_weights: bool = False
    ) -> Dict:
        """
        Computes specified data for a **set** of cases.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        new_cases : list of list of list of object or list, optional
            Specify a **set** using a list of cases to compute the conviction of
            groups of cases as shown in the following example.
        features : iterable of str, optional
            An iterable of feature names to consider while calculating
            convictions.
        trainees_to_compare : iterable of str, optional
            If specified ignores the 'new_cases' parameter and uses
            cases from this other specified trainee instead.
        distance_contributions : bool, default False
            Calculate and output distance contribution ratios in
            the output dict for each case.
        familiarity_conviction_addition : bool, default True
            Calculate and output familiarity conviction of adding the
            specified cases.
        familiarity_conviction_removal : bool, default False
            Calculate and output familiarity conviction of removing
            the specified cases.s
        kl_divergence_addition : bool, default False
            Calculate and output KL divergence of adding the
            specified cases.
        kl_divergence_removal : bool, default False
            Calculate and output KL divergence of removing the
            specified cases.
        p_value_of_addition : bool, default False
            If true will output p value of addition.
        p_value_of_removal : bool, default False
            If true will output p value of removal.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.
        use_case_weights : bool, default False
            If set to True will scale influence weights by each
            case's weight_feature weight.

        Returns
        -------
        dict
            The react response.
        """
        return self._execute("batch_react_group", {
            "trainee": trainee_id,
            "features": features,
            "new_cases": new_cases,
            "trainees_to_compare": trainees_to_compare,
            "distance_contributions": distance_contributions,
            "familiarity_conviction_addition": familiarity_conviction_addition,
            "familiarity_conviction_removal": familiarity_conviction_removal,
            "kl_divergence_addition": kl_divergence_addition,
            "kl_divergence_removal": kl_divergence_removal,
            "p_value_of_addition": p_value_of_addition,
            "p_value_of_removal": p_value_of_removal,
            "weight_feature": weight_feature,
            "use_case_weights": use_case_weights,
        })

    def react_into_trainee(
        self,
        trainee_id: str,
        *,
        action_feature: Optional[str] = None,
        context_features: Optional[Iterable[str]] = None,
        contributions: Optional[bool] = None,
        contributions_robust: Optional[bool] = None,
        hyperparameter_param_path: Optional[Iterable[str]] = None,
        mda: Optional[bool] = None,
        mda_permutation: Optional[bool] = None,
        mda_robust: Optional[bool] = None,
        mda_robust_permutation: Optional[bool] = None,
        num_robust_influence_samples: Optional[int] = None,
        num_robust_residual_samples: Optional[int] = None,
        num_robust_influence_samples_per_case: Optional[int] = None,
        num_samples: Optional[int] = None,
        residuals: Optional[bool] = None,
        residuals_robust: Optional[bool] = None,
        sample_model_fraction: Optional[float] = None,
        sub_model_size: Optional[int] = None,
        use_case_weights: bool = False,
        weight_feature: Optional[str] = None
    ) -> None:
        """
        Compute and cache specified feature prediction stats.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        action_feature : str, optional
            Name of target feature for which to do computations. Default is
            whatever the model was analyzed for, i.e., action feature for MDA
            and contributions, or ".targetless" if analyzed for targetless.
            This parameter is required for MDA or contributions computations.
        context_features : iterable of str, optional
            List of features names to use as contexts for
            computations. Default is all trained non-unique features if
            unspecified.
        contributions: bool, optional
            For each context_feature, use the full set of all other
            context_features to compute the mean absolute delta between
            prediction of action_feature with and without the context_feature
            in the model. False removes cached values.
        contributions_robust: bool, optional
            For each context_feature, use the robust (power set/permutation)
            set of all other context_features to compute the mean absolute
            delta between prediction of action_feature with and without the
            context_feature in the model. False removes cached values.
        hyperparameter_param_path: iterable of str, optional.
            Full path for hyperparameters to use for computation. If specified
            for any residual computations, takes precedence over action_feature
            parameter.  Can be set to a 'paramPath' value from the results of
            'get_params()' for a specific set of hyperparameters.
        mda : bool, optional
            When True will compute Mean Decrease in Accuracy (MDA)
            for each context feature at predicting the action_feature. Drop
            each feature and use the full set of remaining context features
            for each prediction. False removes cached values.
        mda_permutation : bool, optional
            Compute MDA by scrambling each feature and using the
            full set of remaining context features for each prediction.
            False removes cached values.
        mda_robust : bool, optional
            Compute MDA by dropping each feature and using the
            robust (power set/permutations) set of remaining context features
            for each prediction. False removes cached values.
        mda_robust_permutation : bool, optional
            Compute MDA by scrambling each feature and using the
            robust (power set/permutations) set of remaining context features
            for each prediction. False removes cached values.
        num_robust_influence_samples : int, optional
            Total sample size of model to use (using sampling with replacement)
            for robust contribution computation. Defaults to 300.
        num_robust_residual_samples : int, optional
            Total sample size of model to use (using sampling with replacement)
            for robust mda and residual computation.
            Defaults to 1000 * (1 + log(number of features)).  Note: robust mda
            will be updated to use num_robust_influence_samples in a future
            release.
        num_robust_influence_samples_per_case : int, optional
            Specifies the number of robust samples to use for each case for
            robust contribution computations.
            Defaults to 300 + 2 * (number of features).
        num_samples : int, optional
            Total sample size of model to use (using sampling with replacement)
            for all non-robust computation. Defaults to 1000.
            If specified overrides sample_model_fraction.```
        residuals : bool, optional
            For each context_feature, use the full set of all other
            context_features to predict the feature. When True computes and
            caches MAE (mean absolute error), R^2, RMSE (root mean squared
            error), and Spearman Coefficient for continuous features, and
            MAE, accuracy, precision and recall for nominal features.
            False removes cached values.
        residuals_robust : bool, optional
            For each context_feature, computes and caches the same stats as
            residuals but using the robust (power set/permutations) set of all
            other context_features to predict the feature.
            False removes cached values.
        sample_model_fraction : float, optional
            A value between 0.0 - 1.0, percent of model to use in sampling
            (using sampling without replacement). Applicable only to non-robust
            computation. Ignored if num_samples is specified.
            Higher values provide better accuracy at the cost of compute time.
        sub_model_size : int, optional
            Subset of model to use for calculations. Applicable only
            to models > 1000 cases.
        use_case_weights : bool, default False
            If set to True will scale influence weights by each case's
            weight_feature weight.
        weight_feature : str, optional
            The name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.
        """
        self._execute("react_into_trainee", {
            "trainee": trainee_id,
            "context_features": context_features,
            "use_case_weights": use_case_weights,
            "weight_feature": weight_feature,
            "num_samples": num_samples,
            "residuals": residuals,
            "residuals_robust": residuals_robust,
            "contributions": contributions,
            "contributions_robust": contributions_robust,
            "mda": mda,
            "mda_permutation": mda_permutation,
            "mda_robust": mda_robust,
            "mda_robust_permutation": mda_robust_permutation,
            "num_robust_influence_samples": num_robust_influence_samples,
            "num_robust_residual_samples": num_robust_residual_samples,
            "num_robust_influence_samples_per_case":
                num_robust_influence_samples_per_case,
            "hyperparameter_param_path": hyperparameter_param_path,
            "sample_model_fraction": sample_model_fraction,
            "sub_model_size": sub_model_size,
            "action_feature": action_feature
        })

    def compute_conviction_of_features(
        self,
        trainee_id: str,
        *,
        features: Optional[Iterable[str]] = None,
        action_features: Optional[Iterable[str]] = None,
        familiarity_conviction_addition: bool = True,
        familiarity_conviction_removal: bool = False,
        weight_feature: Optional[str] = None,
        use_case_weights: bool = False
    ) -> Dict:
        """
        Get familiarity conviction for features in the model.

        Parameters
        ----------
        trainee_id : str
            The id of the trainee.
        features : iterable of str, optional
            An iterable of feature names to calculate convictions. At least 2
            features are required to get familiarity conviction. If not
            specified all features will be used.
        action_features : iterable of str, optional
            An iterable of feature names to be treated as action features
            during conviction calculation in order to determine the conviction
            of each feature against the set of action_features. If not
            specified, conviction is computed for each feature against the rest
            of the features as a whole.
        familiarity_conviction_addition : bool, default True
            Calculate and output familiarity conviction of adding the
            specified features in the output.
        familiarity_conviction_removal : bool, default False
            Calculate and output familiarity conviction of removing
            the specified features in the output.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.
        use_case_weights : bool, default False
            If set to True will scale influence weights by each
            case's weight_feature weight.

        Returns
        -------
        dict
            A dict with familiarity_conviction_addition or
            familiarity_conviction_removal.
        """
        return self._execute("compute_conviction_of_features", {
            "trainee": trainee_id,
            "features": features,
            "action_features": action_features,
            "familiarity_conviction_addition": familiarity_conviction_addition,
            "familiarity_conviction_removal": familiarity_conviction_removal,
            "weight_feature": weight_feature,
            "use_case_weights": use_case_weights,
        })

    def simplify_model(
        self,
        trainee_id: str,
        num_cases_to_remove: int,
        distribute_weight_feature: str
    ) -> None:
        """Perform data reduction."""
        return self._execute("simplify_model", {
            "trainee": trainee_id,
            "num_cases_to_remove": num_cases_to_remove,
            "distribute_weight_feature": distribute_weight_feature,
        })

    def forget_irrelevant_data(
        self,
        trainee_id: str,
        num_cases_to_remove: int,
        case_access_count_label: str,
        distribute_weight_feature: str
    ) -> None:
        """Perform data reduction."""
        return self._execute("forget_irrelevant_data", {
            "trainee": trainee_id,
            "num_cases_to_remove": num_cases_to_remove,
            "case_access_count_label": case_access_count_label,
            "distribute_weight_feature": distribute_weight_feature,
        })

    def get_session_indices(self, trainee_id: str, session: str) -> List[int]:
        """
        Get list of all session indices for a specified session.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        session : str
            The identifier of the session.

        Returns
        -------
        list of int
            A list of the session indices for the session.
        """
        return self._execute("get_session_indices", {
            "trainee": trainee_id,
            "session": session,
        })

    def get_session_training_indices(self, trainee_id: str, session: str
                                     ) -> List[int]:
        """
        Get list of all session training indices for a specified session.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        session : str
            The identifier of the session.

        Returns
        -------
        list of int
            A list of the session training indices for the session.
        """
        return self._execute("get_session_training_indices", {
            "trainee": trainee_id,
            "session": session,
        })

    def set_internal_parameters(self, trainee_id: str, params: Dict) -> None:
        """
        Sets specific model parameters in the Trainee.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        params : dict
            A dictionary containing the internal parameters.
        """
        return self._execute("set_internal_parameters", {
            "trainee": trainee_id,
            **params
        })

    def set_feature_attributes(
        self,
        trainee_id: str,
        feature_attributes: Dict[str, Dict]
    ) -> Dict[str, Dict]:
        """
        Sets feature attributes for a Trainee.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        feature_attributes : dict of str to dict
            A dictionary of feature name to dictionary of feature attributes.

        Returns
        -------
        dict
            The updated feature attributes.
        """
        return self._execute("set_feature_attributes", {
            "trainee": trainee_id,
            "features": feature_attributes,
        })

    def get_feature_attributes(self, trainee_id: str) -> Dict[str, Dict]:
        """
        Get Trainee feature attributes.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee

        Returns
        -------
        dict
            A dictionary of feature name to dictionary of feature attributes.
        """
        return self._execute("get_feature_attributes", {"trainee": trainee_id})

    def export_trainee(
        self,
        trainee_id: str,
        path_to_trainee: Optional[Union[Path, str]] = None,
        decode_cases: bool = False,
        separate_files: bool = False
    ) -> None:
        """
        Export a saved Trainee's data to json files for migration.

        Parameters
        ----------
        trainee_id : str
            The ID of the Trainee.
        path_to_trainee : Path or str, optional
            The path to where the saved trainee file is located.
        decoded_cases : bool, default False.
            Whether to export decoded cases.
        separate_files : bool, default False
            Whether to load each case from its individual file.
        """
        if path_to_trainee is None:
            path_to_trainee = self.default_save_path

        return self._execute("export_trainee", {
            "trainee": trainee_id,
            "trainee_filepath": f"{path_to_trainee}/",
            "root_filepath": f"{self.howso_path}/",
            "decode_cases": decode_cases,
            "separate_files": separate_files,
        })

    def upgrade_trainee(
        self,
        trainee_id: str,
        path_to_trainee: Optional[Union[Path, str]] = None,
        separate_files: bool = False
    ) -> None:
        """
        Upgrade a saved Trainee to current version.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        path_to_trainee : Path or str, optional
            The path to where the saved Trainee file is located.
        separate_files : bool, default False
            Whether to load each case from its individual file.
        """
        if path_to_trainee is None:
            path_to_trainee = self.default_save_path

        return self._execute("upgrade_trainee", {
            "trainee": trainee_id,
            "trainee_filepath": f"{path_to_trainee}/",
            "root_filepath": f"{self.howso_path}/",
            "separate_files": separate_files,
        })

    def analyze(self, trainee_id: str, **kwargs) -> None:
        """
        Analyzes a trainee.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        kwargs : dict
            Analysis arguments.
        """
        params = {**kwargs, "trainee": trainee_id}
        return self._execute("analyze", params)

    def get_feature_residuals(
        self,
        trainee_id: str,
        action_feature: Optional[str] = None,
        robust: Optional[bool] = None,
        robust_hyperparameters: Optional[bool] = None,
        weight_feature: Optional[str] = None,
    ) -> Dict:
        """
        Get cached feature residuals.

        .. deprecated:: 1.0.0
            Use get_prediction_stats() instead.
        """
        return self._execute("get_feature_residuals", {
            "trainee": trainee_id,
            "robust": robust,
            "action_feature": action_feature,
            "robust_hyperparameters": robust_hyperparameters,
            "weight_feature": weight_feature,
        })

    def get_feature_mda(
        self,
        trainee_id: str,
        action_feature: str,
        permutation: Optional[bool] = None,
        robust: Optional[bool] = None,
        weight_feature: Optional[str] = None,
    ) -> Dict:
        """
        Get cached feature Mean Decrease In Accuracy (MDA).

        .. deprecated:: 1.0.0
            Use get_prediction_stats() instead.
        """
        return self._execute("get_feature_mda", {
            "trainee": trainee_id,
            "robust": robust,
            "action_feature": action_feature,
            "permutation": permutation,
            "weight_feature": weight_feature,
        })

    def get_feature_contributions(
        self,
        trainee_id: str,
        action_feature: str,
        robust: Optional[bool] = None,
        directional: bool = False,
        weight_feature: Optional[str] = None,
    ) -> Dict:
        """
        Get cached feature contributions.

        .. deprecated:: 1.0.0
            Use get_prediction_stats() instead.
        """
        return self._execute("get_feature_contributions", {
            "trainee": trainee_id,
            "robust": robust,
            "action_feature": action_feature,
            "directional": directional,
            "weight_feature": weight_feature,
        })

    def get_prediction_stats(
        self, trainee_id, *,
        action_feature=None,
        condition=None,
        num_cases=None,
        num_robust_influence_samples_per_case=None,
        precision=None,
        robust=None,
        robust_hyperparameters=None,
        stats=None,
        weight_feature=None,
    ) -> Dict:
        """
        Get feature prediction stats.

        Parameters
        ----------
        trainee_id : str
            The id or name of the trainee.
        action_feature : str, optional
            When specified, will attempt to return stats that
            were computed for this specified action_feature.
            Note: ".targetless" is the action feature used during targetless
            analysis.
        condition : dict or None, optional
            A condition map to select which cases to compute prediction stats
            for.
        num_cases : int, default None
            The maximum amount of cases to use to calculate prediction stats.
            If not specified, the limit will be k cases if precision is
            "similar", or 1000 cases if precision is "exact". Only used if
            `condition` is not None.
        num_robust_influence_samples_per_case : int, optional
            Specifies the number of robust samples to use for each case for
            robust contribution computations.
            Defaults to 300 + 2 * (number of features).
        precision : str, default None
            The precision to use when selecting cases with the condition.
            Options are 'exact' or 'similar'. If not specified "exact" will be
            used. Only used if `condition` is not None.
        robust : bool, optional
            When specified, will attempt to return stats that
            were computed with the specified robust or non-robust type.
        robust_hyperparameters : bool, optional
            When specified, will attempt to return stats that were
            computed using hyperparameters with the specified robust or
            non-robust type.
        stats : iterable of str, optional
            List of stats to output. When unspecified, returns all.
        weight_feature : str, optional
            When specified, will attempt to return stats that
            were computed using this weight_feature.

        Returns
        -------
        dict of str to dict of str to float
            A map of feature to map of stat type to stat values.
        """
        return self._execute("get_prediction_stats", {
            "trainee": trainee_id,
            "robust": robust,
            "action_feature": action_feature,
            "condition": condition,
            "num_cases": num_cases,
            "num_robust_influence_samples_per_case": num_robust_influence_samples_per_case,
            "precision": precision,
            "robust_hyperparameters": robust_hyperparameters,
            "stats": stats,
            "weight_feature": weight_feature,
        })

    def get_marginal_stats(
        self,
        trainee_id: str,
        *,
        condition: Optional[Dict[str, Any]] = None,
        num_cases: Optional[int] = None,
        precision: Optional[Literal["exact", "similar"]] = None,
        weight_feature: Optional[str] = None
    ) -> Dict[str, Dict[str, float]]:
        """
        Get marginal stats for all features.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        condition : dict or None, optional
            A condition map to select which cases to compute marginal stats
            for.

            .. NOTE::
                The dictionary keys are the feature name and values are one of:

                    - None
                    - A value, must match exactly.
                    - An array of two numeric values, specifying an inclusive
                      range. Only applicable to continuous and numeric ordinal
                      features.
                    - An array of string values, must match any of these values
                      exactly. Only applicable to nominal and string ordinal
                      features.
        num_cases : int, default None
            The maximum amount of cases to use to calculate marginal stats.
            If not specified, the limit will be k cases if precision is
            "similar". Only used if `condition` is not None.
        precision : str, default None
            The precision to use when selecting cases with the condition.
            Options are 'exact' or 'similar'. If not specified "exact" will be
            used. Only used if `condition` is not None.
        weight_feature : str, optional
            When specified, will attempt to return stats that were computed
            using this weight_feature.

        Returns
        -------
        dict of str to dict of str to float
            A map of feature names to map of stat type to stat values.
        """
        return self._execute("get_marginal_stats", {
            "trainee": trainee_id,
            "condition": condition,
            "num_cases": num_cases,
            "precision": precision,
            "weight_feature": weight_feature,
        })

    def set_random_seed(self, trainee_id: str, seed: Union[int, float, str]
                        ) -> None:
        """
        Sets the random seed for the Trainee.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        seed: int or float or str
            The random seed.
        """
        return self._execute("set_random_seed", {
            "trainee": trainee_id,
            "seed": seed,
        })

    def get_internal_parameters(
        self,
        trainee_id: str,
        *,
        action_feature: Optional[str] = None,
        context_features: Optional[Iterable[str]] = None,
        mode: Optional[Literal["robust", "full"]] = None,
        weight_feature: Optional[str] = None,
    ) -> Dict:
        """
        Get the parameters used by the Trainee.

        If 'action_feature', 'context_features', 'mode', or 'weight_feature'
        are specified, then the best hyperparameters analyzed in the Trainee
        are the value of the 'hyperparameter_map' key, otherwise this value
        will be the dictionary containing all the hyperparameter sets in
        the Trainee.


        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        action_feature : str, optional
            If specified will return the best analyzed hyperparameters to
            target this feature.
        context_features : str, optional
            If specified, will find and return the best analyzed hyperparameters
            to use with these context features.
        mode : str, optional
            If specified, will find and return the best analyzed hyperparameters
            that were computed in this mode.
        weight_feature : str, optional
            If specified, will find and return the best analyzed hyperparameters
            that were analyzed using this weight feaure.

        Returns
        -------
        dict
            A dict including the either all of the Trainee's internal
            parameters or only the best hyperparameters selected using the
            passed parameters.
        """
        return self._execute("get_internal_parameters", {
            "trainee": trainee_id,
            "action_feature": action_feature,
            "context_features": context_features,
            "mode": mode,
            "weight_feature": weight_feature,
        })

    def move_cases(
        self,
        trainee_id: str,
        target_trainee_id: Union[str, None],
        num_cases: int = 1,
        *,
        case_indices: Optional[Iterable[Tuple[str, int]]] = None,
        condition: Optional[Dict] = None,
        condition_session: Optional[str] = None,
        distribute_weight_feature: Optional[str] = None,
        precision: Optional[Literal["exact", "similar"]] = None,
        preserve_session_data: bool = False,
        session: Optional[str] = None
    ) -> Dict:
        """
        Moves cases from one trainee to another trainee.

        Parameters
        ----------
        trainee_id : str
            The identifier of the source Trainee.
        target_trainee_id : str or None
            The identifier of the target Trainee.
        num_cases : int
            The number of cases to move; minimum 1 case must be moved.
            Ignored if case_indices is specified.
        case_indices : list of tuples
            A list of tuples containing session ID and session training index
            for each case to be removed.
        condition : dict, optional
            The condition map to select the cases to move that meet all the
            provided conditions. Ignored if case_indices is specified.
        condition_session : str, optional
            If specified, ignores the condition and operates on cases for
            the specified session id. Ignored if case_indices is specified.
        precision : {"exact", "similar"}, optional
            The precision to use when moving the cases. Options are 'exact'
            or 'similar'. If not specified, "exact" will be used.
            Ignored if case_indices is specified.
        preserve_session_data : bool, default False
            When True, will move cases without cleaning up session data.
        session : str, optional
            The identifier of the Trainee session to associate the move with.

        Returns
        -------
        dict
            A dictionary with key 'count' for the number of moved cases.
        """
        result = self._execute("move_cases", {
            "trainee": trainee_id,
            "target_trainee": target_trainee_id,
            "case_indices": case_indices,
            "condition": condition,
            "condition_session": condition_session,
            "precision": precision,
            "num_cases": num_cases,
            "preserve_session_data": preserve_session_data,
            "session": session,
            "distribute_weight_feature": distribute_weight_feature,
        })
        if not result:
            return {'count': 0}
        return result

    def remove_cases(
        self,
        trainee_id: str,
        num_cases: int = 1,
        *,
        case_indices: Optional[Iterable[Tuple[str, int]]] = None,
        condition: Optional[Dict[str, Any]] = None,
        condition_session: Optional[str] = None,
        distribute_weight_feature: Optional[str] = None,
        precision: Optional[Literal["exact", "similar"]] = None,
        preserve_session_data: bool = False,
        session: Optional[str] = None
    ) -> Dict:
        """
        Removes cases from a Trainee.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        num_cases : int
            The number of cases to remove; minimum 1 case must be removed.
            Ignored if case_indices is specified.
        case_indices : list of tuples
            A list of tuples containing session ID and session training index
            for each case to be removed.
        condition : dict of str to object, optional
            The condition map to select the cases to remove that meet all the
            provided conditions. Ignored if case_indices is specified.
        condition_session : str, optional
            If specified, ignores the condition and operates on cases for
            the specified session id. Ignored if case_indices is specified.
        distribute_weight_feature : str, optional
            When specified, will distribute the removed cases' weights
            from this feature into their neighbors.
        precision : {"exact", "similar"}, optional
            The precision to use when moving the cases, defaults to "exact".
            Ignored if case_indices is specified.
        preserve_session_data : bool, default False
            When True, will remove cases without cleaning up session data.
        session : str, optional
            The identifier of the Trainee session to associate the removal with.

        Returns
        -------
        dict
            A dictionary with key 'count' for the number of removed cases.
        """
        return self.move_cases(
            trainee_id,
            target_trainee_id=None,
            case_indices=case_indices,
            condition=condition,
            condition_session=condition_session,
            num_cases=num_cases,
            precision=precision,
            preserve_session_data=preserve_session_data,
            session=session,
            distribute_weight_feature=distribute_weight_feature,
        )

    def edit_cases(
        self,
        trainee_id: str,
        feature_values: Optional[Iterable[Any]] = None,
        *,
        case_indices: Optional[Iterable[Tuple[str, int]]] = None,
        condition: Optional[Dict[str, Any]] = None,
        condition_session: Optional[str] = None,
        features: Optional[Iterable[str]] = None,
        num_cases: Optional[int] = None,
        precision: Optional[Literal["exact", "similar"]] = None,
        session: Optional[str] = None
    ) -> Dict:
        """
        Edit feature values for the specified cases.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        feature_values : list of object
            The feature values to edit the case(s) with. If specified as a list,
            the order corresponds with the order of the `features` parameter.
        case_indices : Iterable of Sequence[Union[str, int]], optional
            Iterable of Sequences containing the session id and index, where
            index is the original 0-based index of the case as it was trained
            into the session. This explicitly specifies the cases to edit. When
            specified, `condition` and `condition_session` are ignored.
        condition : dict, optional
            A condition map to select which cases to edit. Ignored when
            `case_indices` are specified.
        condition_session : str, optional
            If specified, ignores the condition and operates on all cases for
            the specified session.
        features : iterable of str, optional
            The names of the features to edit. Corresponds to feature_values.
        num_cases : int, default None
            The maximum amount of cases to edit. If not specified, the limit
            will be k cases if precision is "similar", or no limit if precision
            is "exact".
        precision : {"exact", "similar"}, optional
            The precision to use when moving the cases, defaults to "exact".
        session : str, optional
            The identifier of the Trainee session to associate the edit with.

        Returns
        -------
        dict
            A dictionary with key 'count' for the number of modified cases.
        """
        result = self._execute("edit_cases", {
            "trainee": trainee_id,
            "case_indices": case_indices,
            "condition": condition,
            "condition_session": condition_session,
            "features": features,
            "feature_values": feature_values,
            "precision": precision,
            "num_cases": num_cases,
            "session": session,
        })
        if not result:
            return {'count': 0}
        return result

    def pairwise_distances(
        self,
        trainee_id: str,
        features: Optional[Iterable[str]] = None,
        *,
        action_feature: Optional[str] = None,
        from_case_indices: Optional[Iterable[Sequence[Union[str, int]]]] = None,
        from_values: Optional[List[List[Any]]] = None,
        to_case_indices: Optional[Iterable[Sequence[Union[str, int]]]] = None,
        to_values: Optional[List[List[Any]]] = None,
        use_case_weights: bool = False,
        weight_feature: Optional[str] = None
    ) -> List:
        """
        Compute pairwise distances between specified cases.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        features : iterable of str, optional
            List of feature names to use when computing pairwise distances.
            If unspecified uses all features.
        action_feature : str, optional
            The action feature. If specified, uses targeted hyperparameters
            used to predict this `action_feature`, otherwise uses targetless
            hyperparameters.
        from_case_indices : Iterable of Sequence[Union[str, int]], optional
            An iterable of sequences, of session id and index, where index
            is the original 0-based index of the case as it was trained into
            the session. If specified must be either length of 1 or match
            length of `to_values` or `to_case_indices`.
        from_values : list of list of object, optional
            A 2d-list of case values. If specified must be either length of
            1 or match length of `to_values` or `to_case_indices`.
        to_case_indices : Iterable of Sequence[Union[str, int]], optional
            An Iterable of Sequences, of session id and index, where index
            is the original 0-based index of the case as it was trained into
            the session. If specified must be either length of 1 or match
            length of `from_values` or `from_case_indices`.
        to_values : list of list of object, optional
            A 2d-list of case values. If specified must be either length of
            1 or match length of `from_values` or `from_case_indices`.
        use_case_weights : bool, default False
            If set to True, will scale influence weights by each case's
            `weight_feature` weight.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.

        Returns
        -------
        list
            A list of computed pairwise distances between each corresponding
            pair of cases in `from_case_indices` and `to_case_indices`.
        """
        return self._execute("pairwise_distances", {
            "trainee": trainee_id,
            "features": features,
            "action_feature": action_feature,
            "from_case_indices": from_case_indices,
            "from_values": from_values,
            "to_case_indices": to_case_indices,
            "to_values": to_values,
            "weight_feature": weight_feature,
            "use_case_weights": use_case_weights,
        })

    def distances(
        self,
        trainee_id: str,
        features: Optional[Iterable[str]] = None,
        *,
        action_feature: Optional[str] = None,
        case_indices: Optional[Iterable[Sequence[Union[str, int]]]] = None,
        feature_values: Optional[Iterable[Any]] = None,
        use_case_weights: bool = False,
        weight_feature: Optional[str] = None,
        row_offset: int = 0,
        row_count: Optional[int] = None,
        column_offset: int = 0,
        column_count: Optional[int] = None
    ) -> Dict:
        """
        Compute distances matrix for specified cases.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        features : iterable of str, optional
            List of feature names to use when computing distances. If
            unspecified uses all features.
        action_feature : str, optional
            The action feature. If specified, uses targeted hyperparameters
            used to predict this `action_feature`, otherwise uses targetless
            hyperparameters.
        case_indices : Iterable of Sequence[Union[str, int]], optional
            An Iterable of Sequences, of session id and index, where index is
            the original 0-based index of the case as it was trained into the
            session. If specified, returns distances for all of these
            cases. Ignored if `feature_values` is provided. If neither
            `feature_values` nor `case_indices` is specified, uses full dataset.
        feature_values : list of object, optional
            If specified, returns distances of the local model relative to
            these values, ignores `case_indices` parameter.
        use_case_weights : bool, default False
            If set to True, will scale influence weights by each case's
            `weight_feature` weight.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.

        Returns
        -------
        dict
            A dictionary of keys, 'distances', 'row_case_indices' and
            'column_case_indices'.
        """
        return self._execute("distances", {
            "trainee": trainee_id,
            "features": features,
            "action_feature": action_feature,
            "case_indices": case_indices,
            "feature_values": feature_values,
            "weight_feature": weight_feature,
            "use_case_weights": use_case_weights,
            "row_offset": row_offset,
            "row_count": row_count,
            "column_offset": column_offset,
            "column_count": column_count,
        })

    def evaluate(
        self,
        trainee_id: str,
        features_to_code_map: Dict[str, str],
        *,
        aggregation_code: Optional[str] = None
    ) -> Dict:
        """
        Evaluate custom code on feature values of all cases in the trainee.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        features_to_code_map : dict of str to str
            A dictionary with feature name keys and custom Amalgam code string
            values.
        aggregation_code : str, optional
            A string of custom Amalgam code that can access the list of values
            derived form the custom code in features_to_code_map.

        Returns
        -------
        dict
            A dictionary with keys: 'evaluated' and 'aggregated'.
        """
        return self._execute(
            "evaluate",
            {
                "trainee": trainee_id,
                "features_to_code_map": features_to_code_map,
                "aggregation_code": aggregation_code
            })

    def reset_parameter_defaults(self, trainee_id: str) -> None:
        """
        Reset Trainee hyperparameters and thresholds.

        Parameters
        ----------
        trainee_id : str
            The identifier of the Trainee.
        """
        return self._execute(
            "reset_parameter_defaults", {"trainee": trainee_id})

    @classmethod
    def _deserialize(cls, payload):
        """Deserialize core response."""
        try:
            deserialized_payload = json.loads(payload)
            if isinstance(deserialized_payload, dict):
                if deserialized_payload.get('status') != 'ok':
                    # If result is an error, raise it
                    errors = deserialized_payload.get('errors') or []
                    if errors:
                        # Raise first error
                        raise HowsoError(errors[0].get('detail'))
                    else:
                        # Unknown error occurred
                        raise HowsoError('An unknown error occurred while '
                                         'processing the core operation.')

                warning_list = deserialized_payload.get('warnings') or []
                for w in warning_list:
                    warnings.warn(w.get('detail'), category=HowsoWarning)

                return deserialized_payload.get('payload')
            return deserialized_payload
        except HowsoError:
            raise
        except Exception:  # noqa: Deliberately broad
            raise HowsoError('Failed to deserialize the core response.')

    def _get_label(self, label: str) -> Any:
        """Get label value from core."""
        result = self.amlg.get_json_from_label(self.handle, label)
        return result

    def _set_label(self, label: str, payload: Any) -> None:
        """Set label value in core."""
        payload = sanitize_for_json(payload)
        payload = self._remove_null_entries(payload)
        self.amlg.set_json_to_label(
            self.handle, label, json.dumps(payload))

    def _execute(self, label: str, payload: Any) -> Any:
        """
        Execute label in core.

        Parameters
        ----------
        label : str
            The label to execute.
        payload : Any
            The payload to send to label.

        Returns
        -------
        Any
            The label's response.
        """
        payload = sanitize_for_json(payload)
        payload = self._remove_null_entries(payload)
        try:
            result = self.amlg.execute_entity_json(
                self.handle, label, json.dumps(payload))
        except ValueError as err:
            raise HowsoError(
                'Invalid payload - please check for infinity or NaN '
                f'values: {err}')

        if result is None or len(result) == 0:
            return None
        return self._deserialize(result)

    @staticmethod
    def _remove_null_entries(payload) -> Dict:
        """Remove keys from dict whose value is None."""
        return dict((k, v) for k, v in payload.items() if v is not None)

    @classmethod
    def escape_filename(cls, s: str) -> str:
        """Escape filename."""
        escaped = ""
        i = 0
        for i in range(len(s)):
            if cls._is_char_safe(ord(s[i])):
                escaped += s[i]
            else:
                escaped += cls._escape_char(s[i])

        return escaped

    @classmethod
    def unescape_filename(cls, s: str) -> str:
        """Unescape filename."""
        unescaped = ""
        i = 0
        while i < len(s):
            if s[i] == '_' and (i + 2) < len(s):
                unescaped += cls._char_value_from_escape_hex(
                    s[i + 1], s[i + 2])
                i += 3
            else:
                unescaped += s[i]
                i += 1
        return unescaped

    @staticmethod
    def _is_char_safe(c):
        # UTF-8 chars below zero (U+0030) are unsafe
        if c < 0x30:
            return False
        # Chars between 0 and 9 are ok
        if c <= 0x39:
            return True
        # Chars above 9 (U+0039) and below A (U+0041) are unsafe
        if c < 0x41:
            return False
        # Chars between A and Z are ok
        if c <= 0x5A:
            return True
        # Chars between Z and a (exclusive) are unsafe
        if c < 0x61:
            return False
        # Chars between a and z are ok
        if c <= 0x7A:
            return True

        # Any other char is unsafe
        return False

    @classmethod
    def _escape_char(cls, c):
        """Escape character."""
        low = cls._decimal_to_hex(15 & ord(c))
        high = cls._decimal_to_hex(15 & (ord(c) >> 4))
        return '_' + high + low

    @staticmethod
    def _decimal_to_hex(c):
        """Decimal to hex."""
        if c >= 10:
            return chr(c - 10 + ord('a'))
        return chr(c + ord('0'))

    @classmethod
    def _char_value_from_escape_hex(cls, high, low):
        """Character code from hex."""
        chr_int_value = cls._hex_to_decimal(low) + (
            (cls._hex_to_decimal(high) << 4) & 240)
        return chr(chr_int_value)

    @staticmethod
    def _hex_to_decimal(c):
        """Convert hex to decimal."""
        if c >= '0':
            if c <= '9':
                return ord(c) - ord('0')
            if 'a' <= c <= 'f':
                return ord(c) - ord('a') + 10
            if 'A' <= c <= 'F':
                return ord(c) - ord('A') + 10

        # Invalid and possibly unsafe char is not a hex value, return 0 as
        # having no value
        return 0

    @classmethod
    def download_amlg(cls, config):
        """
        Download amalgam binaries.

        Requires the howso-build-artifacts dependency.

        Parameters
        ----------
        config : dict
            The amalgam configuration options.

        Returns
        -------
        Path
            The path to the downloaded amalgam directory. Or None if nothing
            was downloaded.
        """
        # Since direct client may be distributed without build downloads ..
        try:
            from howso.build.artifacts.repo import HowsoArtifactService  # noqa # type: ignore
        except ImportError as err:
            raise ImportError(
                "Amalgam Download functionality only available "
                "if howso-build-artifacts is installed"
            ) from err

        if config is None:
            raise ValueError("config may not be None")

        version = config.get('version', 'latest')
        api_key = config.get('download_apikey')
        repo = config.get('repo')

        service_config = {}
        if api_key:
            service_config['HOWSO_ARTIFACTORY_APIKEY'] = api_key
        if repo:
            repo_path = f'{repo}/amalgam/'
            service_config['HOWSO_AMALGAM_DOWNLOAD_PATH'] = repo_path

        service = HowsoArtifactService(service_config)
        download_dir = service.download_amalgam(
            version=version,
            operating_system=config.get('os'),
            architecture=config.get('arch')
        )

        _logger.info(f'Downloaded amalgam version: {version}')
        return download_dir

    @classmethod
    def download_core(cls, config):
        """
        Download core binaries.

        Requires the howso-build-artifacts dependency.

        Parameters
        ----------
        config : dict
            The core configuration options.

        Returns
        -------
        Path
            The path to the downloaded core directory. Or None if nothing
            was downloaded.
        """
        # Since direct client may be distributed without build downloads ..
        try:
            from howso.build.artifacts.repo import HowsoArtifactService  # noqa # type: ignore
        except ImportError as err:
            raise ImportError(
                "Amalgam Download functionality only available "
                "if howso-build-artifacts is installed"
            ) from err

        version = config.pop('version', 'latest')
        api_key = config.pop('download_apikey', None)
        repo = config.get('repo')

        service_config = {}
        if api_key:
            service_config['HOWSO_ARTIFACTORY_APIKEY'] = api_key
        if repo:
            repo_path = f'{repo}/howso-core/'
            service_config['HOWSO_CORE_DOWNLOAD_PATH'] = repo_path

        service = HowsoArtifactService(service_config)
        download_dir = service.download_core(version=version)

        _logger.info(f'Downloaded core version: {version}')
        return download_dir

    @staticmethod
    def default_library_ext() -> str:
        """Returns the default library extension based on runtime os."""
        if platform.system().lower() == 'windows':
            return ".dll"
        elif platform.system().lower() == 'darwin':
            return ".dylib"
        else:
            return ".so"

howso/direct/core.py
howsoai/howso-engine-py
import pytest

from amalgam.api import Amalgam
from howso.direct import HowsoDirectClient, HowsoCore
from howso.utilities.testing import get_configurationless_test_client


@pytest.fixture
def client():
    """Direct client instance using latest binaries."""
    return get_configurationless_test_client(client_class=HowsoDirectClient,
                                             verbose=True, trace=True)


def test_direct_client(client):
    """Sanity check client instantiation."""
    assert isinstance(client.howso, HowsoCore)
    assert isinstance(client.howso.amlg, Amalgam)
    version = client.get_version()
    assert version.api is not None
    assert version.client is not None


@pytest.mark.parametrize(('filename', 'truthiness'), (
    ('./banana.txt', True),
    ('./ba\nana.txt', True),
    ('./bañän🤣a.txt', True),
))
def test_check_name_valid_for_save(client, filename, truthiness):
    """Ensure that the internal function `check_name_valid_for_save` works."""
    assert client.check_name_valid_for_save(filename, clobber=True)[0] == truthiness

howso/direct/tests/test_standalone.py
howsoai/howso-engine-py
"""The Python API for the Howso Engine Client."""

from .client import get_client, use_client  # noqa: F401
from .project import (  # noqa: F401
    delete_project,
    get_project,
    list_projects,
    Project,
    switch_project,
)
from .session import (  # noqa: F401
    get_active_session,
    get_session,
    list_sessions,
    Session,
)
from .trainee import (  # noqa: F401
    delete_trainee,
    get_trainee,
    list_trainees,
    load_trainee,
    Trainee,
)

__all__ = [
    "delete_project",
    "delete_trainee",
    "get_active_session",
    "get_client",
    "get_project",
    "get_session",
    "get_trainee",
    "list_projects",
    "list_sessions",
    "list_trainees",
    'load_trainee',
    "Project",
    "Session",
    "switch_project",
    "Trainee",
    "use_client"
]

howso/engine/__init__.py
howsoai/howso-engine-py
from howso.client import AbstractHowsoClient, HowsoPandasClient
from howso.client.pandas import HowsoPandasClientMixin

__client_instance = None

__all__ = {
    'get_client',
    'use_client',
}


def get_client() -> AbstractHowsoClient:
    """
    Get the active Howso client instance.

    Returns
    -------
    HowsoPandasClient
        The active client.
    """
    global __client_instance
    if __client_instance is None:
        __client_instance = HowsoPandasClient()
    return __client_instance


def use_client(client: AbstractHowsoClient) -> None:
    """
    Set the active Howso client instance to use for the API.

    Parameters
    ----------
    client : AbstractHowsoClient
        The client instance.

    Returns
    -------
    None

    Raises
    ------
    ValueError
        When the client is not an instance of AbstractHowsoClient.
    """
    global __client_instance
    if not isinstance(client, AbstractHowsoClient):
        raise ValueError("`client` must be a subclass of "
                         "AbstractHowsoClient")
    if not isinstance(client, HowsoPandasClientMixin):
        raise ValueError("`client` must be a HowsoPandasClient")
    __client_instance = client

howso/engine/client.py
howsoai/howso-engine-py
from typing import List, Optional, TYPE_CHECKING

from howso.client.exceptions import HowsoError
from howso.client.protocols import ProjectClient
from howso.engine.client import get_client
from howso.openapi.models import Project as BaseProject

if TYPE_CHECKING:
    from datetime import datetime
    from howso.openapi.models import AccountIdentity

__all__ = [
    'delete_project',
    'get_project',
    'list_projects',
    'Project',
    'switch_project',
]


class Project(BaseProject):
    """
    A Howso Project.

    A Project is a container for a collection of Trainees. Allowing
    control over who may view and modify the Trainees based on their
    membership access to the project.

    Parameters
    ----------
    name : str
        The name of the project.
    client : ProjectClient, optional
        The Howso client instance to use. Must support the project API.
    """

    def __init__(
        self,
        name: str = None,
        *,
        id: Optional[str] = None,
        client: Optional[ProjectClient] = None
    ) -> None:
        """Implement the constructor."""
        self._created: bool = False
        self._updating: bool = False
        self.client = client or get_client()

        # Set the project properties
        self.name = name
        self._is_default = False
        self._created_by = None
        self._created_date = None
        self._id = id

        # Create the project at the API
        self._create()

    @property
    def id(self) -> str:
        """
        The unique identifier of the project.

        Returns
        -------
        str
            The project ID.
        """
        return self._id

    @property
    def name(self) -> str:
        """
        The name of the project.

        Returns
        -------
        str
            The project name.
        """
        return self._name

    @name.setter
    def name(self, name: str) -> None:
        """
        Set the name of the project.

        Parameters
        ----------
        name : str
            The name of the project.

        Returns
        -------
        None
        """
        if name is None:
            raise ValueError("Invalid value for `name`, must not be `None`")
        if len(name) > 128:
            raise ValueError("Invalid value for `name`, length must be less "
                             "than or equal to `128`")
        self._name = name
        self._update()

    @property
    def is_default(self) -> bool:
        """
        If this project is the current user's default project.

        Returns
        -------
        bool
            True, when the project is the user's default.
        """
        return self._is_default

    @property
    def created_by(self) -> Optional["AccountIdentity"]:
        """
        The user account that created this project.

        Returns
        -------
        AccountIdentity
            The user account information.
        """
        return self._created_by

    @property
    def created_date(self) -> Optional["datetime"]:
        """
        The timestamp of when the project was originally created.

        Returns
        -------
        datetime
            The creation timestamp.
        """
        return self._created_date

    @property
    def client(self) -> ProjectClient:
        """
        The client instance used by the project.

        Returns
        -------
        ProjectClient
            The client instance.
        """
        return self._client

    @client.setter
    def client(self, client: ProjectClient) -> None:
        """
        Set the client instance used by the project.

        Parameters
        ----------
        client : ProjectClient
            The client instance. Must support the project API.

        Returns
        -------
        None
        """
        if not isinstance(client, ProjectClient):
            raise HowsoError("Projects are not supported by the active "
                             "Howso client.")
        self._client = client

    def delete(self) -> None:
        """
        Delete the project.

        Projects may only be deleted when they have no trainees in them.

        Returns
        -------
        None
        """
        if not self.id:
            return
        self.client.delete_project(self.id)
        self._created = False
        self._id = None

    def _update_attributes(self, project: BaseProject) -> None:
        """
        Update the protected attributes of the project.

        Parameters
        ----------
        project : BaseProject
            The base project instance.

        Returns
        -------
        None
        """
        for key in self.attribute_map.keys():
            # Update the protected attributes directly since the values
            # have already been validated by the "BaseProject" instance
            # and to prevent triggering an API update call
            setattr(self, f'_{key}', getattr(project, key))

    def _update(self) -> None:
        """
        Update the project at the API.

        Returns
        -------
        None
        """
        if (
            getattr(self, 'id', None)
            and getattr(self, '_created', False)
            and not getattr(self, '_updating', False)
        ):
            # Only update for projects that have been created
            self._updating = True
            updated_project = self.client.update_project(
                project_id=self.id,
                name=self.name
            )
            self._update_attributes(updated_project)
            self._updating = False

    def _create(self) -> None:
        """
        Create the project at the API.

        Returns
        -------
        None
        """
        if not self.id:
            project = self.client.create_project(name=self.name)
            self._update_attributes(project)
        self._created = True

    @classmethod
    def from_openapi(
        cls, project: BaseProject, *,
        client: Optional[ProjectClient] = None
    ) -> "Project":
        """
        Create Project from base class.

        Parameters
        ----------
        project : BaseProject
            The base project instance.
        client : ProjectClient, optional
            The Howso client instance to use.

        Returns
        -------
        Project
            The project instance.
        """
        project_dict = project.to_dict()
        project_dict['client'] = client
        return cls.from_dict(project_dict)

    @classmethod
    def from_dict(cls, project_dict: dict) -> "Project":
        """
        Create Project from dict.

        Parameters
        ----------
        project_dict : Dict
            The Project parameters.

        Returns
        -------
        Project
            The project instance.
        """
        if not isinstance(project_dict, dict):
            raise ValueError('`project_dict` parameter is not a dict')
        parameters = {
            'id': project_dict.get('id'),
            'name': project_dict.get('name'),
            'client': project_dict.get('client')
        }
        instance = cls(**parameters)
        for key in cls.attribute_map.keys():
            if key in project_dict:
                setattr(instance, f'_{key}', project_dict[key])
        return instance

    def __enter__(self) -> "Project":
        """Support context managers."""
        setattr(self, '_last_active_project', self.client.active_project)
        self.client.switch_project(self.id)
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        """Revert project during exit of context manager."""
        last_project = getattr(self, '_last_active_project', None)
        if last_project:
            self.client.switch_project(last_project.id)
            setattr(self, '_last_active_project', None)


def delete_project(
    project_id: str,
    *,
    client: Optional[ProjectClient] = None
) -> None:
    """
    Delete an existing project.

    Projects may only be deleted when they have no trainees in them.

    Parameters
    ----------
    project_id : str
        The id of the project.
    client : ProjectClient, optional
        The Howso client instance to use.

    Returns
    -------
    None
    """
    client = client or get_client()
    client.delete_project(str(project_id))


def get_project(
    project_id: str,
    *,
    client: Optional[ProjectClient] = None
) -> Project:
    """
    Get an existing project.

    Parameters
    ----------
    project_id : str
        The id of the project.
    client : ProjectClient, optional
        The Howso client instance to use.

    Returns
    -------
    Project
        The project instance.
    """
    client = client or get_client()
    if not isinstance(client, ProjectClient):
        raise HowsoError("Projects are not supported by the active "
                         "Howso client.")

    project = client.get_project(str(project_id))
    return Project.from_openapi(project, client=client)


def list_projects(
    search_terms: Optional[str] = None,
    *,
    client: Optional[ProjectClient] = None
) -> List[Project]:
    """
    Get listing of projects.

    Parameters
    ----------
    search_terms : str
        Terms to filter results by.
    client : ProjectClient, optional
        The Howso client instance to use.

    Returns
    -------
    list of Project
        The list of project instances.
    """
    client = client or get_client()
    if not isinstance(client, ProjectClient):
        raise HowsoError("Projects are not supported by the active "
                         "Howso client.")
    projects = client.get_projects(search_terms)
    return [Project.from_openapi(p, client=client) for p in projects]


def switch_project(
    project_id: str,
    *,
    client: Optional[ProjectClient] = None
) -> Project:
    """
    Set the active project.

    Parameters
    ----------
    project_id : str
        The id of the project.
    client : ProjectClient, optional
        The Howso client instance to use.

    Returns
    -------
    Project
        The newly active project instance.
    """
    client = client or get_client()
    if not isinstance(client, ProjectClient):
        raise HowsoError("Projects are not supported by the active "
                         "Howso client.")
    client.switch_project(str(project_id))
    return Project.from_openapi(client.active_project, client=client)

howso/engine/project.py
howsoai/howso-engine-py
from copy import deepcopy
from typing import Any, Dict, List, Optional, TYPE_CHECKING, Union

from howso.client import AbstractHowsoClient
from howso.client.exceptions import HowsoError
from howso.client.protocols import ProjectClient
from howso.engine.client import get_client
from howso.openapi.models import (
    Project as BaseProject,
    Session as BaseSession
)

if TYPE_CHECKING:
    from datetime import datetime
    from howso.openapi.models import AccountIdentity

__all__ = [
    'get_active_session',
    'get_session',
    'list_sessions',
    'Session',
]


class Session(BaseSession):
    """
    A Howso Session.

    Parameters
    ----------
    name : str, optional
        The name of the session.
    metadata : dict, optional
        Any key-value pair to store custom metadata for the session.
    client : AbstractHowsoClient, optional
        The Howso client instance to use.
    """

    def __init__(
        self,
        name: Optional[str] = None,
        *,
        id: Optional[str] = None,
        metadata: Optional[dict] = None,
        client: Optional[AbstractHowsoClient] = None,
    ) -> None:
        """Implement the constructor."""
        self._created: bool = False
        self._updating: bool = False
        self.client = client or get_client()

        # Set the session properties
        self._metadata = metadata
        self._name = name
        self._user = None
        self._created_date = None
        self._modified_date = None
        self._id = id

        # Create the session at the API
        self._create()

    @property
    def id(self) -> str:
        """
        The unique identifier of the session.

        Returns
        -------
        str
            The session ID.
        """
        return self._id

    @property
    def name(self) -> str:
        """
        The name of the session.

        Returns
        -------
        str
            The session name.
        """
        return self._name

    @property
    def user(self) -> Optional["AccountIdentity"]:
        """
        The user account that the session belongs to.

        Returns
        -------
        AccountIdentity
            The user account information.
        """
        return self._user

    @property
    def metadata(self) -> Optional[Dict[str, Any]]:
        """
        The session metadata.

        .. WARNING::
            This returns a deep `copy` of the metadata. To update the
            metadata of the session, use the method :func:`set_metadata`.

        Returns
        -------
        dict
            The metadata of the session.
        """
        return deepcopy(self._metadata)

    @property
    def created_date(self) -> Optional["datetime"]:
        """
        The timestamp of when the session was originally created.

        Returns
        -------
        datetime
            The creation timestamp.
        """
        return self._created_date

    @property
    def modified_date(self) -> Optional["datetime"]:
        """
        The timestamp of when the session was last modified.

        Returns
        -------
        datetime
            The modified timestamp.
        """
        return self._modified_date

    @property
    def client(self) -> AbstractHowsoClient:
        """
        The client instance used by the session.

        Returns
        -------
        AbstractHowsoClient
            The client instance.
        """
        return self._client

    @client.setter
    def client(self, client: AbstractHowsoClient) -> None:
        """
        Set the client instance used by the session.

        Parameters
        ----------
        client : AbstractHowsoClient
            The client instance. Must be a subclass of AbstractHowsoClient.

        Returns
        -------
        None
        """
        if not isinstance(client, AbstractHowsoClient):
            raise HowsoError("`client` must be a subclass of "
                             "AbstractHowsoClient")
        self._client = client

    def set_metadata(self, metadata: Optional[Dict[str, Any]]) -> None:
        """
        Update the session metadata.

        Parameters
        ----------
        metadata : dict or None
            Any key-value pair to store as custom metadata for the session.
            Providing `None` will remove the current metadata.

        Returns
        -------
        None
        """
        self._metadata = metadata
        self._update()

    def _update_attributes(self, session: BaseSession) -> None:
        """
        Update the protected attributes of the session.

        Parameters
        ----------
        session : BaseSession
            The base session instance.

        Returns
        -------
        None
        """
        for key in self.attribute_map.keys():
            # Update the protected attributes directly since the values
            # have already been validated by the "BaseSession" instance
            # and to prevent triggering an API update call
            setattr(self, f'_{key}', getattr(session, key))

    def _update(self) -> None:
        """
        Update the session at the API.

        Returns
        -------
        None
        """
        if (
            getattr(self, 'id', None)
            and getattr(self, '_created', False)
            and not getattr(self, '_updating', False)
        ):
            # Only update for sessions that have been created
            self._updating = True
            updated_session = self.client.update_session(
                session_id=self.id,
                metadata=self.metadata
            )
            self._update_attributes(updated_session)
            self._updating = False

    def _create(self) -> None:
        """
        Create the session at the API.

        Returns
        -------
        None
        """
        if not self.id:
            session = self.client.begin_session(name=self.name,
                                                metadata=self.metadata)
            self._update_attributes(session)
        self._created = True

    @classmethod
    def from_openapi(
        cls, session: BaseSession, *,
        client: Optional[AbstractHowsoClient] = None
    ) -> "Session":
        """
        Create Session from base class.

        Parameters
        ----------
        session : BaseSession
            The base session instance.
        client : AbstractHowsoClient, optional
            The Howso client instance to use.

        Returns
        -------
        Session
            The session instance.
        """
        session_dict = session.to_dict()
        session_dict['client'] = client
        return cls.from_dict(session_dict)

    @classmethod
    def from_dict(cls, session_dict: dict) -> "Session":
        """
        Create Session from dict.

        Parameters
        ----------
        session_dict : Dict
            The session parameters.

        Returns
        -------
        Session
            The session instance.
        """
        if not isinstance(session_dict, dict):
            raise ValueError('`session_dict` parameter is not a dict')
        parameters = {
            'id': session_dict.get('id'),
            'client': session_dict.get('client')
        }
        instance = cls(**parameters)
        for key in cls.attribute_map.keys():
            if key in session_dict:
                setattr(instance, f'_{key}', session_dict[key])
        return instance


def get_active_session(
    *, client: Optional[AbstractHowsoClient] = None
) -> Session:
    """
    Get the active session.

    Parameters
    ----------
    client : AbstractHowsoClient, optional
        The Howso client instance to use.

    Returns
    -------
    Session
        The session instance.
    """
    client = client or get_client()
    return client.active_session


def get_session(
    session_id: str,
    *,
    client: Optional[AbstractHowsoClient] = None
) -> Session:
    """
    Get an existing Session.

    Parameters
    ----------
    session_id : str
        The id of the session.
    client : AbstractHowsoClient, optional
        The Howso client instance to use.

    Returns
    -------
    Session
        The session instance.
    """
    client = client or get_client()
    session = client.get_session(str(session_id))
    return Session.from_openapi(session, client=client)


def list_sessions(
    search_terms: Optional[str] = None,
    *,
    client: Optional[AbstractHowsoClient] = None,
    project: Optional[Union[str, BaseProject]] = None
) -> List[Session]:
    """
    Get listing of Sessions.

    Parameters
    ----------
    search_terms : str
        Terms to filter results by.
    client : AbstractHowsoClient, optional
        The Howso client instance to use.
    project : str or Project, optional
        The instance or id of a project to filter by. Ignored if client
        does not support projects.

    Returns
    -------
    list of Session
        The list of session instances.
    """
    client = client or get_client()

    params = {'search_terms': search_terms}

    # Only pass project_id for platform clients
    if project is not None and isinstance(client, ProjectClient):
        if isinstance(project, BaseProject):
            params["project_id"] = project.id
        else:
            params["project_id"] = project

    sessions = client.get_sessions(**params)
    return [Session.from_openapi(s, client=client) for s in sessions]

howso/engine/session.py
howsoai/howso-engine-py
import pmlb
import pytest

from howso.utilities import infer_feature_attributes


@pytest.fixture(scope="module", name="data")
def load_data():
    return pmlb.fetch_data("iris")


@pytest.fixture(scope="module", name="features")
def guess_features(data):
    return infer_feature_attributes(data)

howso/engine/tests/conftest.py
howsoai/howso-engine-py
from pathlib import Path

from howso.client.exceptions import HowsoError
from howso.engine import delete_trainee, load_trainee, Trainee
from pandas.testing import assert_frame_equal
import pytest


class TestEngine:
    """Test the Howso Engine module."""

    @pytest.fixture(autouse=True)
    def trainee(self, data, features):
        """Return a managed trainee that will delete itself upon completion."""
        t = Trainee(features=features)
        t.train(data)

        try:
            yield t
        except Exception:
            raise
        finally:
            t.delete()

    @pytest.mark.parametrize(
        "from_values,to_values,expected",
        [
            ([[0]], [[0]], 0),
            ([[0]], [[1]], 1),
            ([[0, 0, 0]], [[0, 0, 0]], 0),
            ([[0, 0]], [[1, 0]], 1),
        ],
    )
    def test_pairwise_distances(self, trainee, from_values, to_values, expected):
        """Test get_pairwise_distances returns values from simple vectors."""
        features_list = [str(i) for i in range(len(from_values[0]))]
        result = trainee.get_pairwise_distances(
            features=features_list, from_values=from_values, to_values=to_values
        )
        assert result[0] == expected

    @pytest.mark.parametrize(
        "case_indices,expected",
        [
            ([19, 122], [[0, 0], [0, 0]]),
            ([11, 41, 102], [[0, 0, 0], [0, 0, 0], [0, 0, 0]]),
        ],
    )
    def test_distances_same(self, trainee, features, case_indices, expected):
        """
        Test that get_distances returns values as expected.

        Note that, in the iris dataset, rows 19 and 122 are identical
        and rows 11, 41, and 102 are identical and as such their distances
        should be zero.
        """
        sessions = trainee.get_sessions()
        session = sessions[0]
        session_case_indices = []
        for case_index in case_indices:
            session_case_indices.append((session['id'], case_index))

        result = trainee.get_distances(case_indices=session_case_indices)
        result = result['distances'].values.tolist()

        assert result == expected

    @pytest.mark.parametrize(
        "case_indices,unexpected",
        [
            ([0, 1], [[0, 0], [0, 0]]),
            ([2, 3, 4], [[0, 0, 0], [0, 0, 0], [0, 0, 0]]),
        ],
    )
    def test_distances_different(self, trainee, features, case_indices, unexpected):
        """
        Test that get_distances returns values as expected.

        The indices 0, 1 and 2, 3, and 4 are not the same, so the distance
        should be nonzero.
        """
        sessions = trainee.get_sessions()
        session = sessions[0]
        session_case_indices = []
        for case_index in case_indices:
            session_case_indices.append((session['id'], case_index))

        result = trainee.get_distances(case_indices=session_case_indices)
        result = result['distances'].values.tolist()

        assert result != unexpected

    def test_get_cases(self, trainee):
        """
        Test that get_cases works as expected.

        Test that get_cases works with and without a session ID to
        get the cases in the order they were trained. This functionality
        only works in a single-user environment and assumes a single session.
        """
        c1 = trainee.get_cases()

        sessions = trainee.get_sessions()
        session = sessions[0]
        c2 = trainee.get_cases(session=session['id'])

        assert c1.equals(c2)

    def test_predict(self, trainee):
        """Test that predict returns the same results as react."""
        action_features = ['target']
        context_features = [k for k in trainee.features.keys() if k not in action_features]

        test_data = [[5.5, 3.6, 1.6, 0.2], [5.2, 3.2, 1.2, 0.2]]

        prediction = trainee.predict(test_data, action_features=action_features, context_features=context_features)
        react = trainee.react(test_data, action_features=action_features, context_features=context_features)

        assert_frame_equal(prediction, react['action'])

    @pytest.mark.parametrize(
        "file_path_type",
        [
            ('directory_only'),
            ('full_path'),
            ('name_only')
        ]
    )
    def test_save_load_good(self, trainee, file_path_type):
        """Test valid disk save and load methods."""

        trainee_name = 'save_load_trainee'
        save_example_trainee = trainee.copy(name=trainee_name)

        cwd = Path.cwd()
        current_directory = f"{cwd}/"

        if file_path_type == 'directory_only':
            file_path = current_directory
        elif file_path_type == 'full_path':
            file_path = current_directory + 'save_load_trainee.caml'
        elif file_path_type == 'name_only':
            file_path = 'save_load_trainee.caml'

        # Save Method
        save_example_trainee.save(file_path=file_path)

        # Load
        if file_path_type == 'directory_only':
            file_path = file_path + 'save_load_trainee.caml'
        load_example_trainee = load_trainee(file_path=file_path)

        load_training_cases = load_example_trainee.get_num_training_cases()

        # Delete trainees
        save_example_trainee.delete()
        load_example_trainee.delete()

        assert load_training_cases == 150

    def test_save_load_warning(self, trainee):
        """
        Test that the save and load methods raise warnings when expected.

        The save and load methods that should raise a UserWarning but continue
        to work.
        """
        trainee_name = 'save_load_trainee'
        save_example_trainee = trainee.copy(name=trainee_name)

        cwd = Path.cwd()
        file_path = f"{cwd}/save_load_trainee.json"

        # Save Method
        with pytest.warns(
            UserWarning,
            match=('Filepath with a non `.caml` extension was provided.')
        ):
            save_example_trainee.save(file_path=file_path)

        # Set to correct path
        file_path = f"{cwd}/save_load_trainee.caml"

        load_example_trainee = load_trainee(file_path=file_path)
        load_training_cases = load_example_trainee.get_num_training_cases()

        # Delete
        save_example_trainee.delete()
        load_example_trainee.delete()

        assert load_training_cases == 150

    def test_save_load_bad_load(self):
        """Test bad disk load methods."""

        cwd = Path.cwd()
        current_directory = f"{cwd}/"
        file_path = current_directory

        # Load
        with pytest.raises(
            HowsoError,
            match='A `.caml` file must be provided.'
        ):
            load_trainee(file_path=file_path)

    def test_delete_method_standalone_good(self, trainee):
        """Test the standalone trainee deletion method for both strings and Path"""

        # Non-default directory
        directory_path = Path('test_directory')

        # Path and string file path
        Path_file_path = directory_path.joinpath('Path_save_load_trainee.caml')
        string_file_path = str(directory_path.joinpath('string_save_load_trainee.caml'))

        # Save two trainees to test deletion
        trainee.save(file_path=Path_file_path)
        trainee.save(file_path=string_file_path)

        # Delete both trainee's using Path and string file paths
        delete_trainee(file_path=Path_file_path)
        delete_trainee(file_path=string_file_path)

        # Checks to make sure directory is empty
        assert not any(Path_file_path.parents[0].iterdir())

        # Cleanup
        directory_path.rmdir()

    def test_delete_method_trainee_good_save(self, trainee):
        """Test the Trainee deletion function method for saved trainee, should
        delete from last saved location."""

        # Non-default directory
        directory_path = Path('test_directory')

        trainee_name = 'delete_trainee'
        delete_example_trainee = trainee.copy(name=trainee_name)

        # Path and string file path
        file_path = directory_path.joinpath(f'Path_{trainee_name}.caml')

        # Save trainee to test deletion
        delete_example_trainee.save(file_path=file_path)

        # Make sure delete works on saved trainee
        delete_example_trainee.delete()

        # Checks to make sure directory is empty
        assert not any(file_path.parents[0].iterdir())

        # Cleanup
        directory_path.rmdir()

    def test_delete_method_trainee_load_good(self, trainee):
        """Test the Trainee deletion function method for loaded trainee, should
        delete from loaded location."""

        # Non-default directory
        directory_path = Path('test_directory')

        trainee_name = 'delete_trainee'
        delete_example_trainee = trainee.copy(name=trainee_name)

        # Path and string file path
        file_path = directory_path.joinpath(f'Path_{trainee_name}.caml')

        delete_example_trainee.save(file_path=file_path)

        # Make sure delete works on loaded trainee
        delete_trainee = load_trainee(file_path)
        delete_trainee.delete()

        # Checks to make sure directory is empty
        assert not any(file_path.parents[0].iterdir())

        # remove from memory
        delete_example_trainee.delete()

        # Cleanup
        directory_path.rmdir()

    def test_delete_method_standalone_bad(self):
        """Test attempting to delete non-existant trainee."""

        directory_path = Path('test_directory')
        file_path = directory_path.joinpath('Path_non_existant.caml')

        # Delete
        with pytest.raises(
            ValueError,
            match='does not exist.'
        ):
            delete_trainee(file_path=file_path)

howso/engine/tests/test_engine.py
howsoai/howso-engine-py
from copy import deepcopy
from pathlib import Path
from typing import (
    Any,
    Callable,
    Dict,
    Iterable,
    List,
    Literal,
    Optional,
    Tuple,
    TYPE_CHECKING,
    TypedDict,
    Union,
)
import uuid
import warnings

from howso.client import AbstractHowsoClient
from howso.client.exceptions import HowsoApiError, HowsoError
from howso.client.pandas import HowsoPandasClientMixin
from howso.client.protocols import ProjectClient
from howso.direct import HowsoDirectClient
from howso.engine.client import get_client
from howso.engine.project import Project
from howso.engine.session import Session
from howso.openapi.models import (
    Project as BaseProject,
    Session as BaseSession,
    Trainee as BaseTrainee,
)
from howso.utilities import CaseIndices
from howso.utilities.feature_attributes.base import SingleTableFeatureAttributes

if TYPE_CHECKING:
    from howso.openapi.models import (
        Metrics,
        TraineeIdentity,
        TraineeInformation,
        TraineeResources,
    )
    from pandas import DataFrame, Index

    class Reaction(TypedDict):
        """React response format."""

        action: DataFrame
        explanation: Dict[str, Any]

    class Distances(TypedDict):
        """Distances response format."""

        session_indices: List[Tuple[str, int]]
        distances: DataFrame

__all__ = [
    "Trainee",
    "list_trainees",
    "get_trainee",
    "delete_trainee",
]


class Trainee(BaseTrainee):
    """
    A Howso Trainee.

    A Trainee is most closely related to what would normally be called a
    'model' in Machine Learning. It contains feature information,
    training cases, session data, parameters, and other metadata. A Trainee is
    actually a little more abstract than a model which is why we don't use
    the terms interchangeably.

    Parameters
    ----------
    name : str, optional
        The name of the trainee.
    features : dict of {str: dict}
        The feature attributes of the trainee. Where feature `name` is the key
        and a sub dictionary of feature attributes is the value.
    default_action_features : list of str, optional
        The default action feature names of the trainee.
    default_context_features : list of str, optional
        The default context feature names of the trainee.
    library_type : str, optional
        The library type of the Trainee. Valid options include:

            - "st": use single-threaded library.
            - "mt": use multi-threaded library.
    max_wait_time : int or float, default 30
        The number of seconds to wait for a trainee to be created and become
        available before aborting gracefully. Set to `0` (or None) to wait as
        long as the system-configured maximum for sufficient resources to
        become available, which is typically 20 minutes.
    persistence : str, default "allow"
        The requested persistence state of the trainee. Allowed values include
        "allow", "always", and "never".
    project : str or Project, optional
        The instance or id of the project to use for the trainee.
    metadata : dict, optional
        Any key-value pair to store as custom metadata for the trainee.
    resources : TraineeResources or dict, optional
        Customize the resources provisioned for the Trainee instance.
    client : AbstractHowsoClient, optional
        The Howso client instance to use.
    overwrite_existing : bool, default False
        Overwrite existing trainee with the same name (if exists).
    """

    def __init__(
        self,
        name: Optional[str] = None,
        features: Optional[Dict[str, Dict]] = None,
        *,
        default_action_features: Optional[Iterable[str]] = None,
        default_context_features: Optional[Iterable[str]] = None,
        id: Optional[str] = None,
        library_type: Optional[str] = None,
        max_wait_time: Optional[Union[int, float]] = None,
        metadata: Optional[Dict[str, Any]] = None,
        persistence: Optional[str] = "allow",
        project: Optional[Union[str, BaseProject]] = None,
        resources: Optional[Union["TraineeResources", Dict[str, Any]]] = None,
        client: Optional[AbstractHowsoClient] = None,
        overwrite_existing: Optional[bool] = False,
    ) -> None:
        """Implement the constructor."""
        self._created: bool = False
        self._updating: bool = False
        self._was_saved: bool = False
        self.client = client or get_client()

        # Set the trainee properties
        self._features = features
        self._metadata = metadata
        self._id = id
        self._custom_save_path = None

        self.name = name
        self.persistence = persistence
        self.set_default_features(
            action_features=default_action_features,
            context_features=default_context_features,
        )

        # Allow passing project id or the project instance
        if isinstance(project, BaseProject):
            self._project_id = project.id
            self._project_instance = Project.from_openapi(
                project, client=self.client)
        else:
            self._project_id = project
            self._project_instance = None  # lazy loaded

        # Create the trainee at the API
        self._create(
            library_type=library_type,
            max_wait_time=max_wait_time,
            overwrite=overwrite_existing,
            resources=resources,
        )

    @property
    def id(self) -> str:
        """
        The unique identifier of the trainee.

        Returns
        -------
        str
            The trainee's ID.
        """
        return self._id

    @property
    def project_id(self) -> Optional[str]:
        """
        The unique identifier of the trainee's project.

        Returns
        -------
        str or None
            The trainee's project ID.
        """
        return self._project_id

    @property
    def project(self) -> Optional[Project]:
        """
        The trainee's project.

        Returns
        -------
        Project or None
            The trainee's project.
        """
        if (
            not self.project_id or
            not isinstance(self.client, ProjectClient)
        ):
            return None

        if (
            self._project_instance is None or
            self._project_instance.id != self.project_id
        ):
            project = self.client.get_project(self.project_id)
            self._project_instance = Project.from_openapi(
                project, client=self.client)

        return self._project_instance

    @property
    def save_location(self) -> str:
        """
        The current storage location of the trainee.

        Returns
        -------
        Path
            The current storage location of the trainee based on the last saved location or the location
            from which the trainee was loaded from. If not saved or loaded from a custom location, then
            the default save location will be returned.
        """
        if self._custom_save_path:
            return self._custom_save_path
        else:
            return self.client.howso.default_save_path

    @property
    def name(self) -> Optional[str]:
        """
        The name of the trainee.

        Returns
        -------
        str or None
            The name.
        """
        return self._name

    @name.setter
    def name(self, name: Optional[str]) -> None:
        """
        Set the name of the trainee.

        Parameters
        ----------
        name : str or None
            The name.

        Returns
        -------
        None
        """
        if name is not None and len(name) > 128:
            raise ValueError(
                "Invalid value for `name`, length must be less "
                "than or equal to `128`"
            )
        self._name = name
        self.update()

    @property
    def persistence(self) -> str:
        """
        The persistence state of the trainee.

        Returns
        -------
        str
            The trainee's persistence value.
        """
        return self._persistence

    @persistence.setter
    def persistence(self, persistence) -> None:
        """
        Set the persistence state of the trainee.

        Parameters
        ----------
        persistence : str
            The persistence value. Allowed values include: "allow", "always",
            and "never".

        Returns
        -------
        None
        """
        allowed_values = ["allow", "always", "never"]
        if persistence not in allowed_values:
            raise ValueError(
                f"Invalid value for `persistence` ({persistence}), must be"
                f"one of {allowed_values}"
            )
        self._persistence = persistence
        self.update()

    @property
    def features(self) -> SingleTableFeatureAttributes:
        """
        The trainee feature attributes.

        .. WARNING::
            This returns a deep `copy` of the feature attributes. To update
            features attributes of the trainee, use the method
            :func:`set_feature_attributes`.

        Returns
        -------
        FeatureAttributesBase
            The feature attributes of the trainee.
        """
        return SingleTableFeatureAttributes(deepcopy(self._features))

    @property
    def metadata(self) -> Optional[Dict[str, Any]]:
        """
        The trainee metadata.

        .. WARNING::
            This returns a deep `copy` of the metadata. To update the
            metadata of the trainee, use the method :func:`set_metadata`.

        Returns
        -------
        dict
            The metadata of the trainee.
        """
        return deepcopy(self._metadata)

    @property
    def default_action_features(self) -> Optional[List[str]]:
        """
        The default action features of the trainee.

        .. WARNING::
            This returns a deep `copy` of the default action features. To
            update them, use the method :func:`set_default_features`.

        Returns
        -------
        list of str or None
            The default action feature names for the trainee.
        """
        return deepcopy(self._default_action_features)

    @property
    def default_context_features(self) -> Optional[List[str]]:
        """
        The default context features of the trainee.

        .. WARNING::
            This returns a deep `copy` of the default context features. To
            update them, use the method :func:`set_default_features`.

        Returns
        -------
        list of str or None
            The default context feature names for the trainee.
        """
        return deepcopy(self._default_context_features)

    @property
    def active_session(self) -> Session:
        """
        The active session.

        Returns
        -------
        Session
            The session instance.
        """
        return Session.from_openapi(self.client.active_session, client=self.client)

    def save(self, file_path: Optional[Union[Path, str]] = None) -> None:
        """
        Save a Trainee to disk.

        Parameters
        ----------
        file_path : Path or str, optional
            The path of the file to save the Trainee to. This path can contain
            an absolute path, a relative path or simply a file name. If no filepath
            is provided, the default filepath will be the CWD.

            If `file_path` is a relative path (with or without a file name),
            the absolute path will be computed appending the `file_path` to the
            CWD.

            If `file_path` is an absolute path, this is the absolute path that
            will be used.

            If `file_path` does not contain a filename, then the natural
            trainee name will be used `<uuid>.caml`.
        """
        if not isinstance(self.client, HowsoDirectClient):
            raise HowsoError("To save, `client` must be HowsoDirectClient.")

        if file_path:
            if not isinstance(file_path, Path):
                file_path = Path(file_path)

            file_path = file_path.expanduser().resolve()

            # It is decided that if the file contains a suffix then it contains a
            # file name.
            if file_path.suffix:
                # If the final suffix is NOT ".caml", then add the suffix ".caml".
                if file_path.suffix.lower() != '.caml':
                    file_path = file_path.parent.joinpath(f"{file_path.stem}.caml")
                    warnings.warn(
                        'Filepath with a non `.caml` extension was provided. Extension will be '
                        'ignored and the file be will be saved as a `.caml` file', UserWarning)
            else:
                # Add the natural name to the file_path
                file_path = file_path.joinpath(f"{self.id}.caml")

            # If path is not absolute, append it to the default directory.
            if not file_path.is_absolute():
                file_path = self.client.howso.default_save_path.joinpath(file_path)

            # Ensure the parent path exists.
            if not file_path.parents[0].exists():
                file_path.parents[0].mkdir(parents=True, exist_ok=True)

            self._custom_save_path = file_path
            file_name = file_path.stem
            file_path = f"{file_path.parents[0]}/"
        else:
            file_name = None

        self.client.howso.persist(
            trainee_id=self.id,
            filename=file_name,
            filepath=file_path
        )

    def set_feature_attributes(self, feature_attributes: Dict[str, Dict]) -> None:
        """
        Update the trainee feature attributes.

        Parameters
        ----------
        feature_attributes : dict of {str: dict}
            The feature attributes of the trainee. Where feature `name` is the
            key and a sub dictionary of feature attributes is the value.

        Returns
        -------
        None
        """
        self.client.set_feature_attributes(
            trainee_id=self.id, feature_attributes=feature_attributes
        )
        self._features = self.client.trainee_cache.get(self.id).features

    def set_default_features(
        self,
        *,
        action_features: Optional[Iterable[str]],
        context_features: Optional[Iterable[str]],
    ) -> None:
        """
        Update the trainee default features.

        Parameters
        ----------
        action_features : list of str or None
            The default action feature names.
        context_features : list of str or None
            The default context feature names.

        Returns
        -------
        None
        """
        if action_features is not None:
            self._default_action_features = list(action_features)
        else:
            self._default_action_features = None
        if context_features is not None:
            self._default_context_features = list(context_features)
        else:
            self._default_context_features = None
        self.update()

    def set_metadata(self, metadata: Optional[Dict[str, Any]]) -> None:
        """
        Update the trainee metadata.

        Parameters
        ----------
        metadata : dict or None
            Any key-value pair to store as custom metadata for the trainee.
            Providing `None` will remove the current metadata.

        Returns
        -------
        None
        """
        self._metadata = metadata
        self.update()

    def copy(
        self,
        name: Optional[str] = None,
        *,
        library_type: Optional[str] = None,
        project: Optional[Union[str, BaseProject]] = None,
        resources: Optional[Union["TraineeResources", Dict[str, Any]]] = None
    ) -> "Trainee":
        """
        Copy the trainee to another trainee.

        Parameters
        ----------
        name : str, optional
            The name of the new trainee.
        library_type : str, optional
            The library type of the Trainee. If not specified, the new trainee
            will inherit the value from the original. Valid options include:

                - "st": use single-threaded library.
                - "mt": use multi-threaded library.
        project : str or Project, optional
            The instance or id of the project to use for the new trainee.
        resources : TraineeResources or dict, optional
            Customize the resources provisioned for the Trainee instance. If
            not specified, the new trainee will inherit the value from the
            original.

        Returns
        -------
        Trainee
            The new trainee copy.
        """
        if isinstance(project, BaseProject):
            project_id = project.id
        else:
            project_id = project or self.project_id

        params = {
            "trainee_id": self.id,
            "new_trainee_name": name,
            "library_type": library_type,
            "resources": resources,
        }

        # Only pass project_id for platform clients
        if isinstance(self.client, ProjectClient):
            params["project_id"] = project_id

        copy = self.client.copy_trainee(**params)

        return Trainee.from_openapi(copy, client=self.client)

    def persist(self) -> None:
        """
        Persist the trainee.

        Returns
        -------
        None
        """
        self.client.persist_trainee(self.id)
        self._was_saved = True

    def delete(self) -> None:
        """
        Delete the trainee from the last loaded or saved location.

        If trying to delete a trainee from another location, see :func:`delete_trainee`.

        Returns
        -------
        None
        """

        if isinstance(self.client, HowsoDirectClient) and self._custom_save_path is not None:
            self.client.delete_trainee(trainee_id=self.id, file_path=self._custom_save_path)
        else:
            if not self.id:
                return
            self.client.delete_trainee(trainee_id=self.id)

        self._created = False
        self._id = None

    def unload(self) -> None:
        """
        Unload the trainee.

        .. deprecated:: 1.0.0
            Use :meth:`Trainee.release_resources` instead.

        Returns
        -------
        None
        """
        warnings.warn(
            'The method `unload()` is deprecated and will be removed '
            'in a future release. Please use `release_resources()` '
            'instead.', DeprecationWarning)
        self.release_resources()

    def acquire_resources(self, *, max_wait_time=None) -> None:
        """
        Acquire resources for a trainee in the Howso service.

        Parameters
        ----------
        max_wait_time : int or float, default 60
            The number of seconds to wait for trainee resources to be acquired
            before aborting gracefully. Set to `0` (or None) to wait as long as
            the system-configured maximum for sufficient resources to become
            available, which is typically 20 minutes.

        Returns
        -------
        None
        """
        self.client.acquire_trainee_resources(
            self.id, max_wait_time=max_wait_time)

    def release_resources(self) -> None:
        """
        Release a trainee's resources from the Howso service.

        Returns
        -------
        None
        """
        if not self.id:
            return
        self.client.release_trainee_resources(self.id)

    def information(self) -> "TraineeInformation":
        """
        Get detail information about the trainee.

        Returns
        -------
        TraineeInformation
            The trainee detail information. Including trainee version and
            configuration parameters.
        """
        return self.client.get_trainee_information(self.id)

    def metrics(self) -> "Metrics":
        """
        Get metric information of the trainee.

        Returns
        -------
        Metrics
            The trainee metric information. Including cpu and memory.
        """
        return self.client.get_trainee_metrics(self.id)

    def set_random_seed(self, seed: Union[int, float, str]) -> None:
        """
        Set the random seed for the trainee.

        Parameters
        ----------
        seed : int or float or str
            The random seed.

        Returns
        -------
        None
        """
        self.client.set_random_seed(trainee_id=self.id, seed=seed)

    def train(
        self,
        cases: Union[List[List[object]], "DataFrame"],
        *,
        ablatement_params: Optional[Dict[str, List[object]]] = None,
        accumulate_weight_feature: Optional[str] = None,
        batch_size: Optional[int] = None,
        derived_features: Optional[Iterable[str]] = None,
        features: Optional[Iterable[str]] = None,
        input_is_substituted: Optional[bool] = False,
        progress_callback: Optional[Callable] = None,
        series: Optional[str] = None,
        train_weights_only: Optional[bool] = False,
        validate: bool = True,
    ) -> None:
        """
        Train one or more cases into the trainee (model).

        Parameters
        ----------
        cases : list of list of object or pandas.DataFrame
            One or more cases to train into the model.
        ablatement_params : dict [str, list of obj], optional
            A dict of feature name to threshold type.
            Valid thresholds include:

                - ['exact']: Don't train if prediction matches exactly
                - ['tolerance', MIN, MAX]: Don't train if ``prediction
                  >= (case value - MIN) & prediction <= (case value + MAX)``
                - ['relative', PERCENT]: Don't train if
                  ``abs(prediction - case value) / prediction <= PERCENT``
                - ['residual']: Don't train if
                  ``abs(prediction - case value) <= feature residual``

        accumulate_weight_feature : str, default None
            Name of feature into which to accumulate neighbors'
            influences as weight for ablated cases. If unspecified, will not
            accumulate weights.
        batch_size : int or None, optional
            Define the number of cases to train at once. If left unspecified,
            the batch size will be determined automatically.
        derived_features : list of str, optional
            List of feature names for which values should be derived
            in the specified order. If this list is not provided, features with
            the 'auto_derive_on_train' feature attribute set to True will be
            auto-derived. If provided an empty list, no features are derived.
            Any derived_features that are already in the 'features' list will
            not be derived since their values are being explicitly provided.
        features : list of str, optional
            A list of feature names.
            This parameter should be provided in the following scenarios:

                a. When cases are not in the format of a DataFrame, or
                   the DataFrame does not define named columns.
                b. You want to train only a subset of columns defined in your
                   cases DataFrame.
                c. You want to re-order the columns that are trained.

        input_is_substituted : bool, default False
            If True assumes provided nominal feature values have already
            been substituted.
        progress_callback : callable or None, optional
            (Optional) A callback method that will be called before each
            batched call to train and at the end of training. The method is
            given a ProgressTimer containing metrics on the progress and timing
            of the train operation.
        series : str, optional
            The name of the series to pull features and case values from
            internal series storage. If specified, trains on all cases that
            are stored in the internal series store for the specified series.
            The trained feature set is the combined features from storage and
            the passed in features. If cases is of length one, the value(s) of
            this case are appended to all cases in the series. If cases is the
            same length as the series, the value of each case in cases is
            applied in order to each of the cases in the series.
        train_weights_only:  bool, default False
            When true, and accumulate_weight_feature is provided,
            will accumulate all of the cases' neighbor weights instead of
            training the cases into the model.
        validate : bool, default True
            Whether to validate the data against the provided feature
            attributes. Issues warnings if there are any discrepancies between
            the data and the features dictionary.

        Returns
        -------
        None
        """
        self.client.train(
            trainee_id=self.id,
            ablatement_params=ablatement_params,
            accumulate_weight_feature=accumulate_weight_feature,
            batch_size=batch_size,
            cases=cases,
            derived_features=derived_features,
            features=features,
            input_is_substituted=input_is_substituted,
            progress_callback=progress_callback,
            series=series,
            train_weights_only=train_weights_only,
            validate=validate,
        )

    def optimize(self, *args, **kwargs):
        """
        Optimizes a trainee.

        .. deprecated:: 6.0.0
            Use :meth:`Trainee.analyze` instead.

        Parameters
        ----------
        context_features : iterable of str, optional
            The context features to optimize for.
        action_features : iterable of str, optional
            The action features to optimize for.
        k_folds : int
            optional, (default 6) number of cross validation folds to do
        bypass_hyperparameter_optimization : bool
            optional, bypasses hyperparameter optimization
        bypass_calculate_feature_residuals : bool
            optional, bypasses feature residual calculation
        bypass_calculate_feature_weights : bool
            optional, bypasses calculation of feature weights
        use_deviations : bool
            optional, uses deviations for LK metric in queries
        num_samples : int
            used in calculating feature residuals
        k_values : list of int
            optional list used in hyperparameter search
        p_values : list of float
            optional list used in hyperparameter search
        dwe_values : list of float
            optional list used in hyperparameter search
        optimize_level : int
            optional value, if specified, will optimize for the following
            flows:

                1. predictions/accuracy (hyperparameters)
                2. data synth (cache: global residuals)
                3. standard explanations
                4. full analysis
        targeted_model : {"omni_targeted", "single_targeted", "targetless"}
            optional, valid values as follows:

                "single_targeted" = optimize hyperparameters for the
                    specified action_features
                "omni_targeted" = optimize hyperparameters for each context
                    feature as an action feature, ignores action_features
                    parameter
                "targetless" = optimize hyperparameters for all context
                    features as possible action features, ignores
                    action_features parameter
        num_optimization_samples : int, optional
            If the dataset size to too large, optimize on
            (randomly sampled) subset of data. The
            `num_optimization_samples` specifies the number of
            observations to be considered for optimization.
        optimization_sub_model_size : int or Node, optional
            Number of samples to use for optimization. The rest
            will be randomly held-out and not included in calculations.
        inverse_residuals_as_weights : bool, default is False
            When True will compute and use inverse of residuals
            as feature weights
        use_case_weights : bool, default False
            When True will scale influence weights by each
            case's weight_feature weight.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.
        kwargs
            Additional experimental optimize parameters.
        """
        warnings.warn(
            'The method `optimize()` is deprecated and will be'
            'removed in a future release. Please use `analyze()` '
            'instead.', DeprecationWarning)

        self.analyze(*args, **kwargs)

    def auto_optimize(self):
        """
        Auto-optimize the trainee model.

        Re-uses all parameters from the previous optimize or
        set_auto_optimize_params call. If optimize or set_auto_optimize_params
        has not been previously called, auto_optimize will default to a robust
        and versatile optimization.

        .. deprecated:: 6.0.0
            Use :meth:`Trainee.auto_analyze` instead.
        """
        warnings.warn(
            'The method `auto_optimize()` is deprecated and will be'
            'removed in a future release. Please use `auto_analyze()` '
            'instead.', DeprecationWarning)

        return self.auto_analyze()

    def set_auto_optimize_params(self, *args, **kwargs):
        """
        Set trainee parameters for auto optimization.

        .. deprecated:: 6.0.0
            Use :meth:`Trainee.set_auto_analyze_params` instead.

        Parameters
        ----------
        auto_optimize_enabled : bool, default False
            When True, the :func:`train` method will trigger an optimize when
            it's time for the model to be optimized again.
        optimize_threshold : int, optional
            The threshold for the number of cases at which the model should be
            re-optimized.
        auto_optimize_limit_size : int, optional
            The size of of the model at which to stop doing auto-optimization.
            Value of 0 means no limit.
        optimize_growth_factor : float, optional
            The factor by which to increase the optimize threshold every time
            the model grows to the current threshold size.
        kwargs : dict, optional
            Parameters specific for optimize() may be passed in via kwargs, and
            will be cached and used during future auto-optimizations.
        """
        warnings.warn(
            'The method `set_auto_optimize_params()` is deprecated and will be'
            'removed in a future release. Please use `set_auto_analyze_params()` '
            'instead.', DeprecationWarning)

        self.set_auto_analyze_params(*args, **kwargs)

    def auto_analyze(self) -> None:
        """
        Auto-analyze the trainee.

        Re-use all parameters from the previous analyze call, assuming that
        the user has called 'analyze' before. If not, it will default to a
        robust and versatile analysis.

        Returns
        -------
        None
        """
        self.client.auto_analyze(self.id)

    def set_auto_analyze_params(
        self,
        auto_analyze_enabled: bool = False,
        analyze_threshold: Optional[int] = None,
        *,
        auto_analyze_limit_size: Optional[int] = None,
        analyze_growth_factor: Optional[float] = None,
        **kwargs,
    ) -> None:
        """
        Set parameters for auto analysis.

        Auto-analysis is disabled if this is called without specifying an
        analyze_threshold.

        Parameters
        ----------
        auto_analyze_enabled : bool, default False
            When True, the :func:`train` method will trigger an analyze when
            it's time for the model to be analyzed again.
        analyze_threshold : int, optional
            The threshold for the number of cases at which the model should be
            re-analyzed.
        auto_analyze_limit_size : int, optional
            The size of the model at which to stop doing auto-analysis.
            Value of 0 means no limit.
        analyze_growth_factor : float, optional
            The factor by which to increase the analysis threshold every
            time the model grows to the current threshold size.
        kwargs : dict, optional
            See parameters in :func:`analyze`.

        Returns
        -------
        None
        """
        self.client.set_auto_analyze_params(
            trainee_id=self.id,
            auto_analyze_enabled=auto_analyze_enabled,
            auto_analyze_limit_size=auto_analyze_limit_size,
            analyze_growth_factor=analyze_growth_factor,
            analyze_threshold=analyze_threshold,
            **kwargs,
        )

    def analyze(
        self,
        context_features: Optional[Iterable[str]] = None,
        action_features: Optional[Iterable[str]] = None,
        *,
        bypass_calculate_feature_residuals: Optional[bool] = None,
        bypass_calculate_feature_weights: Optional[bool] = None,
        bypass_hyperparameter_analysis: Optional[bool] = None,
        dt_values: Optional[List[float]] = None,
        inverse_residuals_as_weights: Optional[bool] = None,
        k_folds: Optional[int] = None,
        k_values: Optional[List[int]] = None,
        num_analysis_samples: Optional[int] = None,
        num_samples: Optional[int] = None,
        analysis_sub_model_size: Optional[int] = None,
        analyze_level: Optional[int] = None,
        p_values: Optional[List[float]] = None,
        targeted_model: Optional[str] = None,
        use_case_weights: Optional[bool] = None,
        use_deviations: Optional[bool] = None,
        weight_feature: Optional[str] = None,
        **kwargs
    ) -> None:
        """
        Analyzes the trainee.

        Parameters
        ----------
        context_features : list of str, optional
            The context features to analyze for.
        action_features : list of str, optional
            The action features to analyze for.
        bypass_calculate_feature_residuals : bool, default False
            When True, bypasses calculation of feature residuals.
        bypass_calculate_feature_weights : bool, default False
            When True, bypasses calculation of feature weights.
        bypass_hyperparameter_analysis : bool, default False
            When True, bypasses hyperparameter analysis.
        dt_values : list of float, optional
            The dt value hyperparameters to analyze with.
        inverse_residuals_as_weights : bool, default is False
            When True, will compute and use inverse of residuals as feature
            weights.
        k_folds : int, optional
            The number of cross validation folds to do. A value of 1 does
            hold-one-out instead of k-fold.
        k_values : list of int, optional
            The k value hyperparameters to analyze with.
        num_analysis_samples : int, optional
            Specifies the number of observations to be considered for
            analysis.
        num_samples : int, optional
            Number of samples used in calculating feature residuals.
        analysis_sub_model_size : int, optional
            Number of samples to use for analysis. The rest will be
            randomly held-out and not included in calculations.
        analyze_level : int, optional
            If specified, will analyze for the following flows:

                1. Predictions/accuracy (hyperparameters)
                2. Data synth (cache: global residuals)
                3. Standard explanations
                4. Full analysis

        p_values : list of float, optional
            The p value hyperparameters to analyze with.
        targeted_model : str or None
            Type of hyperparameter targeting.
            Valid options include:

                - **single_targeted**: Analyze hyperparameters for the
                  specified action_features.
                - **omni_targeted**: Analyze hyperparameters for each context
                  feature as an action feature, ignores action_features
                  parameter.
                - **targetless**: Analyze hyperparameters for all context
                  features as possible action features, ignores
                  action_features parameter.

        use_case_weights : bool, default False
            (Optional) When True will scale influence weights by each
            case's weight_feature weight.
        use_deviations : bool, default False
            When True, uses deviations for LK metric in queries.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.
        kwargs
            Additional experimental analyze parameters.

        Returns
        -------
        None
        """
        self.client.analyze(
            trainee_id=self.id,
            action_features=action_features,
            context_features=context_features,
            bypass_calculate_feature_residuals=bypass_calculate_feature_residuals,  # noqa: E501
            bypass_calculate_feature_weights=bypass_calculate_feature_weights,
            bypass_hyperparameter_analysis=bypass_hyperparameter_analysis,  # noqa: E501
            dt_values=dt_values,
            use_case_weights=use_case_weights,
            inverse_residuals_as_weights=inverse_residuals_as_weights,
            k_folds=k_folds,
            k_values=k_values,
            num_analysis_samples=num_analysis_samples,
            num_samples=num_samples,
            analysis_sub_model_size=analysis_sub_model_size,
            analyze_level=analyze_level,
            p_values=p_values,
            targeted_model=targeted_model,
            use_deviations=use_deviations,
            weight_feature=weight_feature,
            **kwargs
        )

    def predict(
        self,
        contexts: Optional[Union[List[List[object]], "DataFrame"]] = None,
        *,
        action_features: Optional[Iterable[str]] = None,
        allow_nulls: Optional[bool] = False,
        case_indices: Optional[CaseIndices] = None,
        context_features: Optional[Iterable[str]] = None,
        derived_action_features: Optional[Iterable[str]] = None,
        derived_context_features: Optional[Iterable[str]] = None,
        leave_case_out: Optional[bool] = None,
        suppress_warning: Optional[bool] = False,
        use_case_weights: Optional[bool] = False,
        weight_feature: Optional[str] = None,
    ) -> list:
        """
        Wrapper around :func:`react`.

        Performs a discriminative react to predict the action feature values based on the
        given contexts. Returns only the predicted action values.

        .. seealso::
            :func:`react`

        Parameters
        ----------
        contexts : list of list of object or pandas.DataFrame, optional
            (Optional) The context values to react to. If context values are not specified,
            then `case_indices` must be specified.
        action_features : list of str, optional
            (Optional) Feature names to treat as action features during react. If no
            `action_features` is specified, the Trainee `default_action_features`
            is used.
        allow_nulls : bool, default False, optional
            See parameter ``allow_nulls`` in :func:`react`.
        case_indices : Iterable of Sequence[str, int], default None, optional
            (Optional) Iterable of Sequences, of session id and index, where index
            is the original 0-based index of the case as it was trained into
            the session. If this case does not exist, discriminative react
            outputs null.
        context_features : list of str, optional
            (Optional) Feature names to treat as context features during react. If no
            `context_features` is specified, then the Trainee's `default_action_features`
            are used. If the Trainee has no `default_action_features`, then
            `context_features` will be all of the `features` excluding the
            `action_features`.
        derived_action_features : list of str, optional
            See parameter ``derived_action_features`` in :func:`react`.
        derived_context_features : list of str, optional
            See parameter ``derived_context_features`` in :func:`react`.
        leave_case_out : bool, default False
            See parameter ``leave_case_out`` in :func:`react`.
        suppress_warning : bool, default False
            See parameter ``suppress_warning`` in :func:`react`.
        use_case_weights : bool, default False
            See parameter ``use_case_weights`` in :func:`react`.
        weight_feature : str, optional
            See parameter ``weight_feature`` in :func:`react`.

        Returns
        -------
        pandas.DataFrame
            DataFrame consisting of the discriminative predicted results.
        """
        if action_features is None:
            if self.default_action_features is None:
                raise HowsoError(
                    "No action features specified and no default action features are present. "
                    "Please specify the action feature or add default action features "
                    "to the Trainee."
                )
            else:
                action_features = self.default_action_features

        if context_features is None:
            if self.default_context_features is None:
                context_features = [key for key in self.features.keys() if key not in action_features]
            else:
                context_features = self.default_context_features

        results = self.react(
            action_features=action_features,
            allow_nulls=allow_nulls,
            case_indices=case_indices,
            contexts=contexts,
            context_features=context_features,
            derived_action_features=derived_action_features,
            derived_context_features=derived_context_features,
            leave_case_out=leave_case_out,
            suppress_warning=suppress_warning,
            use_case_weights=use_case_weights,
            weight_feature=weight_feature,
        )

        return results['action']

    def react(
        self,
        contexts: Optional[Union[List[List[object]], "DataFrame"]] = None,
        *,
        action_features: Optional[Iterable[str]] = None,
        actions: Optional[Union[List[List[object]], "DataFrame"]] = None,
        allow_nulls: Optional[bool] = False,
        case_indices: Optional[CaseIndices] = None,
        context_features: Optional[Iterable[str]] = None,
        derived_action_features: Optional[Iterable[str]] = None,
        derived_context_features: Optional[Iterable[str]] = None,
        desired_conviction: Optional[float] = None,
        details: Optional[Dict[str, object]] = None,
        feature_bounds_map: Optional[Dict[str, Dict[str, object]]] = None,
        generate_new_cases: Optional[str] = "no",
        input_is_substituted: Optional[bool] = False,
        into_series_store: Optional[str] = None,
        leave_case_out: Optional[bool] = None,
        new_case_threshold: Optional[str] = "min",
        num_cases_to_generate: Optional[int] = 1,
        ordered_by_specified_features: Optional[bool] = False,
        preserve_feature_values: Optional[Iterable[str]] = None,
        progress_callback: Optional[Callable] = None,
        substitute_output: Optional[bool] = True,
        suppress_warning: Optional[bool] = False,
        use_case_weights: Optional[bool] = False,
        use_regional_model_residuals: Optional[bool] = True,
        weight_feature: Optional[str] = None,
    ) -> "Reaction":
        """
        React to the trainee.

        If `desired_conviction` is specified, executes a generative react,
        producing `action_values` for the specified `action_features`
        conditioned on the optionally provided `contexts`.

        If `desired_conviction` is **not** specified, executes a discriminative
        react. Provided a list of `contexts`, the trainee reacts to the model
        and produces predictions for the specified actions.

        Parameters
        ----------
        contexts : list of list of object or pandas.DataFrame, optional
            The context values to react to.
        action_features : list of str, optional
            Feature names to treat as action features during react.
        actions : list of list of object or pandas.DataFrame, optional
            One or more action values to use for action features.
            If specified, will only return the specified explanation
            details for the given actions. (Discriminative reacts only)
        allow_nulls : bool, default False
            (Optional) When true will allow return of null values if there
            are nulls in the local model for the action features, applicable
            only to discriminative reacts.
        case_indices : Iterable of Sequence[str, int], defaults to None
            (Optional) Iterable of Sequences, of session id and index, where index
            is the original 0-based index of the case as it was trained into
            the session. If this case does not exist, discriminative react
            outputs null, generative react ignores it.
        context_features : list of str, optional
            Feature names to treat as context features during react.
        derived_action_features : list of str, optional
            Features whose values should be computed after reaction from
            the resulting case prior to output, in the specified order.
            Must be a subset of action_features.

            .. NOTE::
                Both of these derived feature lists rely on the features'
                "derived_feature_code" attribute to compute the values. If the
                "derived_feature_code" attribute is undefined or references a
                non-0 feature indices, the derived value will be null.

        derived_context_features : list of str, optional
            Features whose values should be computed from the provided
            context in the specified order.
        desired_conviction : float, optional
            If specified will execute a generative react. If not
            specified will execute a discriminative react. Conviction is the
            ratio of expected surprisal to generated surprisal for each
            feature generated, valid values are in the range of (0,infinity].
        details : dict of {str: object}
            If details are specified, the response will contain the requested
            explanation data along with the reaction. Below are the valid keys
            and data types for the different audit details. Omitted keys,
            values set to None, or False values for Booleans will not be
            included in the audit data returned.

            - boundary_cases : bool, optional
                If True, outputs an automatically determined (when
                'num_boundary_cases' is not specified) relevant number of
                boundary cases. Uses both context and action features of the
                reacted case to determine the counterfactual boundary based on
                action features, which maximize the dissimilarity of action
                features while maximizing the similarity of context features.
                If action features aren't specified, uses familiarity conviction
                to determine the boundary instead.
            - boundary_cases_familiarity_convictions : bool, optional
                If True, outputs familiarity conviction of addition for each of
                the boundary cases.
            - case_contributions : bool, optional
                If True, outputs each influential case's differences between the
                predicted action feature value and the predicted action feature
                value if each individual case were not included. Uses only the
                context features of the reacted case to determine that area.
                Relies on 'robust_computation' parameter to determine whether
                to do standard or robust computation.
            - case_feature_residuals : bool, optional
                If True, outputs feature residuals for all (context and action)
                features for just the specified case. Uses leave-one-out for
                each feature, while using the others to predict the left out
                feature with their corresponding values from this case. Relies
                on 'robust_computation' parameter to determine whether to do
                standard or robust computation.
            - case_mda : bool, optional
                If True, outputs each influential case's mean decrease in
                accuracy of predicting the action feature in the local model
                area, as if each individual case were included versus not
                included. Uses only the context features of the reacted case to
                determine that area. Relies on 'robust_computation' parameter
                to determine whether to do standard or robust computation.
            - categorical_action_probabilities : bool, optional
                If True, outputs probabilities for each class for the action.
                Applicable only to categorical action features.
            - distance_contribution : bool, optional
                If True, outputs the distance contribution (expected total
                surprisal contribution) for the reacted case. Uses both context
                and action feature values.
            - distance_ratio : bool, optional
                If True, outputs the ratio of distance (relative surprisal)
                between this reacted case and its nearest case to the minimum
                distance (relative surprisal) in between the closest two cases
                in the local area. All distances are computed using only the
                specified context features.
            - feature_contributions : bool, optional
                If True, outputs each context feature's differences between the
                predicted action feature value and the predicted action feature
                value if each context were not in the model for all context
                features in the local model area. Relies on 'robust_computation'
                parameter to determine whether to do standard or robust
                computation.
            - case_feature_contributions: bool, optional
                If True outputs each context feature's differences between the
                predicted action feature value and the predicted action feature
                value if each context feature were not in the model for all
                context features in this case, using only the values from this
                specific case. Relies on 'robust_computation' parameter to
                determine whether to do standard or robust computation.
            - feature_mda : bool, optional
                If True, outputs each context feature's mean decrease in
                accuracy of predicting the action feature given the context.
                Uses only the context features of the reacted case to determine
                that area. Relies on 'robust_computation' parameter to
                determine whether to do standard or robust computation.
            - feature_mda_ex_post : bool, optional
                If True, outputs each context feature's mean decrease in
                accuracy of predicting the action feature as an explanation
                given that the specified prediction was already made as
                specified by the action value. Uses both context and action
                features of the reacted case to determine that area. Relies on
                'robust_computation' parameter to determine whether to do
                standard or robust computation.
            - feature_residuals : bool, optional
                If True, outputs feature residuals for all (context and action)
                features locally around the prediction. Uses only the context
                features of the reacted case to determine that area. Relies on
                'robust_computation' parameter to determine whether to do
                standard or robust computation.
            - global_case_feature_residual_convictions : bool, optional
                If True, outputs this case's feature residual convictions for
                the global model. Computed as: global model feature residual
                divided by case feature residual. Relies on
                'robust_computation' parameter to determine whether to do
                standard or robust computation.
            - hypothetical_values : dict, optional
                A dictionary of feature name to feature value. If specified,
                shows how a prediction could change in a what-if scenario where
                the influential cases' context feature values are replaced with
                the specified values.  Iterates over all influential cases,
                predicting the action features each one using the updated
                hypothetical values. Outputs the predicted arithmetic over the
                influential cases for each action feature.
            - influential_cases : bool, optional
                If True, outputs the most influential cases and their influence
                weights based on the surprisal of each case relative to the
                context being predicted among the cases. Uses only the context
                features of the reacted case.
            - influential_cases_familiarity_convictions :  bool, optional
                If True, outputs familiarity conviction of addition for each of
                the influential cases.
            - influential_cases_raw_weights : bool, optional
                If True, outputs the surprisal for each of the influential
                cases.
            - local_case_feature_residual_convictions : bool, optional
                If True, outputs this case's feature residual convictions for
                the region around the prediction. Uses only the context
                features of the reacted case to determine that region.
                Computed as: region feature residual divided by case feature
                residual. Relies on 'robust_computation' parameter to determine
                whether to do standard or robust computation.
            - most_similar_cases : bool, optional
                If True, outputs an automatically determined (when
                'num_most_similar_cases' is not specified) relevant number of
                similar cases, which will first include the influential cases.
                Uses only the context features of the reacted case.
            - num_boundary_cases : int, optional
                Outputs this manually specified number of boundary cases.
            - num_most_similar_cases : int, optional
                Outputs this manually specified number of most similar cases,
                which will first include the influential cases.
            - num_most_similar_case_indices : int, optional
                Outputs this specified number of most similar case indices when
                'distance_ratio' is also set to True.
            - num_robust_influence_samples_per_case : int, optional
                Specifies the number of robust samples to use for each case.
                Applicable only for computing robust feature contributions or
                robust case feature contributions. Defaults to 2000. Higher
                values will take longer but provide more stable results.
            - observational_errors : bool, optional
                If True, outputs observational errors for all features as
                defined in feature attributes.
            - outlying_feature_values : bool, optional
                If True, outputs the reacted case's context feature values that
                are outside the min or max of the corresponding feature values
                of all the cases in the local model area. Uses only the context
                features of the reacted case to determine that area.
            - similarity_conviction : bool, optional
                If True, outputs similarity conviction for the reacted case.
                Uses both context and action feature values as the case values
                for all computations. This is defined as expected (local)
                distance contribution divided by reacted case distance
                contribution.
            - robust_computation : bool, optional
                Default is False, uses leave-one-out for features (or cases,
                as needed) for all relevant computations. If True, uses
                uniform sampling from the power set of all combinations of
                features (or cases, as needed) instead.

        feature_bounds_map : dict of {str: dict of {str: object}}, optional
            A mapping of feature names to the bounds for the feature values to
            be generated in. For continuous features this should be a numeric
            value, for datetimes this should be a datetime string or a numeric
            epoch value. Min bounds should be equal to or smaller than max
            bounds, except when setting the bounds around the cycle length of
            a cyclic feature. (e.g., to allow 0 +/- 60 degrees, set min=300
            and max=60).

            Example::

                {
                    "feature_a": {"min": 0},
                    "feature_b" : {"min": 1, "max": 5},
                    "feature_c": {"max": 1}
                }

        generate_new_cases : str, default "no"
            This parameter takes in a string that may be one of the following:

                - **attempt**: `Synthesizer` attempts to generate new cases and
                  if its not possible to generate a new case, it might
                  generate cases in "no" mode (see point c.)
                - **always**: `Synthesizer` always generates new cases and
                  if its not possible to generate a new case, it returns
                  `None`.
                - **no**: `Synthesizer` generates data based on the
                  `desired_conviction` specified and the generated data is
                  not guaranteed to be a new case (that is, a case not found
                  in original dataset.)

        input_is_substituted : bool, default False
            When True, assumes provided categorical (nominal or ordinal)
            feature values have already been substituted.
        into_series_store : str, optional
            The name of a series store. If specified, will store an internal
            record of all react contexts for this session and series to be used
            later with train series.
        leave_case_out : bool, default False
            When True and specified along with `case_indices`, each individual
            react will respectively ignore the corresponding case specified
            by `case_indices` by leaving it out.
        new_case_threshold : str, default None
            (Optional) Distance to determine the privacy cutoff. If None,
            will default to "min".

            Possible values:

                - min: minimum distance in the original local space.
                - max: maximum distance in the original local space.
                - most_similar: distance between the nearest neighbor to the nearest
                  neighbor in the original space.
        num_cases_to_generate : int, default 1
            The number of cases to generate.
        ordered_by_specified_features : bool, default False
            When True, the order of generated feature values will match
            the order of specified features.
        preserve_feature_values : list of str, optional
            Features that will preserve their values from the case specified
            by `case_indices`, appending and overwriting the specified
            contexts as necessary. For generative reacts, if `case_indices`
            isn't specified will preserve feature values of a random case.
        progress_callback : callable or None, optional
            (Optional) A callback method that will be called before each
            batched call to react and at the end of reacting. The method is
            given a ProgressTimer containing metrics on the progress and timing
            of the react operation, and the batch result.
        substitute_output : bool, default True
            When False, will not substitute categorical feature values. Only
            applicable if a substitution value map has been set.
        suppress_warning : bool, default False
            When True, warnings will not be displayed.
        use_case_weights : bool, default False
            When True, will scale influence weights by each case's
            `weight_feature` weight.
        use_regional_model_residuals : bool, default True
            When false, uses model feature residuals. When True, recalculates
            regional model residuals.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.

        Returns
        -------
        dict of {action: pandas.DataFrame, explanation: dict}
            The action values and explanations.
        """
        return self.client.react(
            trainee_id=self.id,
            action_features=action_features,
            actions=actions,
            allow_nulls=allow_nulls,
            case_indices=case_indices,
            contexts=contexts,
            context_features=context_features,
            derived_action_features=derived_action_features,
            derived_context_features=derived_context_features,
            desired_conviction=desired_conviction,
            details=details,
            feature_bounds_map=feature_bounds_map,
            generate_new_cases=generate_new_cases,
            input_is_substituted=input_is_substituted,
            into_series_store=into_series_store,
            leave_case_out=leave_case_out,
            new_case_threshold=new_case_threshold,
            num_cases_to_generate=num_cases_to_generate,
            ordered_by_specified_features=ordered_by_specified_features,
            preserve_feature_values=preserve_feature_values,
            progress_callback=progress_callback,
            substitute_output=substitute_output,
            suppress_warning=suppress_warning,
            use_case_weights=use_case_weights,
            use_regional_model_residuals=use_regional_model_residuals,
            weight_feature=weight_feature,
        )

    def react_series(
        self,
        contexts: Optional[Union[List[List[object]], "DataFrame"]] = None,
        *,
        action_features: Optional[Iterable[str]] = None,
        actions: Optional[Union[List[List[object]], "DataFrame"]] = None,
        case_indices: Optional[CaseIndices] = None,
        context_features: Optional[Iterable[str]] = None,
        continue_series: Optional[bool] = False,
        derived_action_features: Optional[Iterable[str]] = None,
        derived_context_features: Optional[Iterable[str]] = None,
        desired_conviction: Optional[float] = None,
        details: Optional[Dict[str, object]] = None,
        feature_bounds_map: Optional[Dict[str, Dict[str, object]]] = None,
        final_time_steps: Optional[List[object]] = None,
        generate_new_cases: Optional[str] = "no",
        series_index: Optional[str] = ".series",
        init_time_steps: Optional[List[object]] = None,
        initial_features: Optional[Iterable[str]] = None,
        initial_values: Optional[Union[List[List[object]], "DataFrame"]] = None,
        input_is_substituted: Optional[bool] = False,
        leave_case_out: Optional[bool] = None,
        max_series_lengths: Optional[List[int]] = None,
        new_case_threshold: Optional[str] = "min",
        num_series_to_generate: Optional[int] = 1,
        ordered_by_specified_features: Optional[bool] = False,
        output_new_series_ids: Optional[bool] = True,
        preserve_feature_values: Optional[Iterable[str]] = None,
        progress_callback: Optional[Callable] = None,
        series_context_features: Optional[Iterable[str]] = None,
        series_context_values: Optional[
            Union[List[List[List[object]]], List["DataFrame"]]
        ] = None,
        series_id_tracking: Optional[Literal["fixed", "dynamic", "no"]] = "fixed",
        series_stop_maps: Optional[List[Dict[str, Dict[str, object]]]] = None,
        substitute_output: Optional[bool] = True,
        suppress_warning: Optional[bool] = False,
        use_case_weights: Optional[bool] = False,
        use_regional_model_residuals: Optional[bool] = True,
        weight_feature: Optional[str] = None,
    ) -> "DataFrame":
        """
        React to the trainee in a series until a stop condition is met.

        Aggregates rows of data corresponding to the specified context, action,
        derived_context and derived_action features, utilizing previous rows to
        derive values as necessary. Outputs an dict of "action_features" and
        corresponding "series" where "series" is the completed 'matrix' for
        the corresponding action_features and derived_action_features.

        Parameters
        ----------
        contexts : list of list of object or pandas.DataFrame, optional
            The context values to react to.
        action_features : list of str, optional
            See parameter ``action_features`` in :func:`react`.
        actions : list of list of object or pandas.DataFrame, optional
            See parameter ``actions`` in :func:`react`.
        case_indices : Iterable of Sequence[str, int]
            See parameter ``case_indices`` in :func:`react`.
        context_features : list of str, optional
            See parameter ``context_features`` in :func:`react`.
        continue_series : bool, default False
            When True will attempt to continue existing series instead of
            starting new series. If `initial_values` provide series IDs, it
            will continue those explicitly specified IDs, otherwise it will
            randomly select series to continue.
            .. note::

                Terminated series with terminators cannot be continued and
                will result in null output.

        derived_action_features : list of str, optional
            See parameter ``derived_action_features`` in :func:`react`.
        derived_context_features : list of str, optional
            See parameter ``derived_context_features`` in :func:`react`.
        desired_conviction : float, optional
            See parameter ``desired_conviction`` in :func:`react`.
        details : dict of {str: object}
            See parameter ``details`` in :func:`react`.
        feature_bounds_map : dict of {str: dict of {str: object}}, optional
            See parameter ``feature_bounds_map`` in :func:`react`.
        final_time_steps: list of object, optional
            The time steps at which to end synthesis. Time-series only.
            Time-series only. Must provide either one for all series, or
            exactly one per series.
        generate_new_cases : str, default "no"
            See parameter ``generate_new_cases`` in :func:`react`.
        series_index : str, default ".series"
            When set to a string, will include the series index as a
            column in the returned DataFrame using the column name given.
            If set to None, no column will be added.
        init_time_steps: list of object, optional
            The time steps at which to begin synthesis. Time-series only.
            Time-series only. Must provide either one for all series, or
            exactly one per series.
        initial_features : list of str, optional
            Features to condition just the first case in a series,
            overwrites context_features and derived_context_features for that
            first case. All specified initial features must be in one of:
            context_features, action_features, derived_context_features or
            derived_action_features. If provided a value that isn't in one of
            those lists, it will be ignored.
        initial_values : list of list of object or pandas.DataFrame, optional
            Values corresponding to the initial_features, used to condition
            just the first case in each series. Must provide either exactly one
            value to use for all series, or one per series.
        input_is_substituted : bool, default False
            See parameter ``input_is_substituted`` in :func:`react`.
        leave_case_out : bool, default False
            See parameter ``leave_case_out`` in :func:`react`.
        max_series_lengths : list of int, optional
            Maximum size a series is allowed to be. A 0 or less is no limit.
            Must provide either exactly one to use for all series, or one per
            series. Default is ``3 * model_size``
        new_case_threshold : str or None, optional
            (Optional) See parameter ``new_case_threshold`` in :func:`react`.
        num_series_to_generate : int, default 1
            The number of series to generate.
        ordered_by_specified_features : bool, default False
            See parameter ``ordered_by_specified_features`` in :func:`react`.
        output_new_series_ids : bool, default True
            If True, series ids are replaced with unique values on output.
            If False, will maintain or replace ids with existing trained values,
            but also allows output of series with duplicate existing ids.
        preserve_feature_values : list of str, optional
            See parameter ``preserve_feature_values`` in :func:`react`.
        progress_callback : callable or None, optional
            (Optional) A callback method that will be called before each
            batched call to react series and at the end of reacting. The method
            is given a ProgressTimer containing metrics on the progress and
            timing of the react series operation, and the batch result.
        series_context_features :  list of str, default None
            (Optional) list of context features corresponding to
            series_context_values, if specified must not overlap with any
            initial_features or context_features.
        series_context_values : list of list of list of object or list of pandas.DataFrame, default None
            (Optional) 3d-list of context values, one for each feature for each
            row for each series. If specified, batch_size and
            max_series_lengths are ignored.
        series_id_tracking : {"fixed", "dynamic", "no"}, default "fixed"
            Controls how closely generated series should follow existing series (plural).

            - If "fixed", tracks the particular relevant series ID.
            - If "dynamic", tracks the particular relevant series ID, but is allowed to
              change the series ID that it tracks based on its current context.
            - If "no", does not track any particular series ID.
        series_stop_maps : list of dict of {str: dict}, optional
            Map of series stop conditions. Must provide either exactly one to
            use for all series, or one per series.

            .. TIP::
                Stop series when value exceeds max or is smaller than min::

                    {"feature_name":  {"min" : 1, "max": 2}}

                Stop series when feature value matches any of the values
                listed::

                    {"feature_name":  {"values": ["val1", "val2"]}}

        substitute_output : bool, default True
            See parameter ``substitute_output`` in :func:`react`.
        suppress_warning : bool, default False
            See parameter ``suppress_warning`` in :func:`react`.
        use_case_weights : bool, default False
            See parameter ``use_case_weights`` in :func:`react`.
        use_regional_model_residuals : bool, default True
            See parameter ``use_regional_model_residuals`` in :func:`react`.
        weight_feature : str, optional
            See parameter ``weight_feature`` in :func:`react`.

        Returns
        -------
        pandas.DataFrame
            The series action values.
        """
        return self.client.react_series(
            trainee_id=self.id,
            action_features=action_features,
            actions=actions,
            case_indices=case_indices,
            contexts=contexts,
            context_features=context_features,
            continue_series=continue_series,
            derived_action_features=derived_action_features,
            derived_context_features=derived_context_features,
            desired_conviction=desired_conviction,
            details=details,
            feature_bounds_map=feature_bounds_map,
            final_time_steps=final_time_steps,
            generate_new_cases=generate_new_cases,
            series_index=series_index,
            init_time_steps=init_time_steps,
            initial_features=initial_features,
            initial_values=initial_values,
            input_is_substituted=input_is_substituted,
            leave_case_out=leave_case_out,
            max_series_lengths=max_series_lengths,
            new_case_threshold=new_case_threshold,
            num_series_to_generate=num_series_to_generate,
            ordered_by_specified_features=ordered_by_specified_features,
            output_new_series_ids=output_new_series_ids,
            preserve_feature_values=preserve_feature_values,
            progress_callback=progress_callback,
            series_context_features=series_context_features,
            series_context_values=series_context_values,
            series_id_tracking=series_id_tracking,
            series_stop_maps=series_stop_maps,
            substitute_output=substitute_output,
            suppress_warning=suppress_warning,
            use_case_weights=use_case_weights,
            use_regional_model_residuals=use_regional_model_residuals,
            weight_feature=weight_feature,
        )

    def impute(
        self,
        *,
        batch_size: Optional[int] = 1,
        features: Optional[Iterable[str]] = None,
        features_to_impute: Optional[Iterable[str]] = None,
    ) -> None:
        """
        Impute (fill) the missing values for the specified features_to_impute.

        If no 'features' are specified, will use all features in the trainee
        for imputation. If no 'features_to_impute' are specified, will impute
        all features specified by 'features'.

        Parameters
        ----------
        batch_size : int, default 1
            Larger batch size will increase speed but decrease
            accuracy. Batch size indicates how many rows to fill before
            recomputing conviction.

            The default value (which is 1) should return the best accuracy but
            might be slower. Higher values should improve performance but may
            decrease accuracy of results.
        features : list of str, optional
            A list of feature names to use for imputation. If not specified,
            all features will be used.
        features_to_impute : list of str, optional
            A list of feature names to impute. If not specified, features
            will be used.

        Returns
        -------
        None
        """
        self.client.impute(
            trainee_id=self.id,
            batch_size=batch_size,
            features=features,
            features_to_impute=features_to_impute,
        )

    def remove_cases(
        self,
        num_cases: int,
        *,
        case_indices: Optional[Iterable[Tuple[str, int]]] = None,
        condition: Optional[Dict[str, object]] = None,
        condition_session: Optional[Union[str, BaseSession]] = None,
        distribute_weight_feature: Optional[str] = None,
        precision: Optional[str] = None,
        preserve_session_data: bool = False
    ) -> int:
        """
        Remove training cases from the trainee.

        The training cases will be completely purged from the model and
        the model will behave as if it had never been trained with them.

        Parameters
        ----------
        num_cases : int
            The number of cases to remove; minimum 1 case must be removed.
            Ignored if case_indices is specified.
        case_indices : list of tuples
            A list of tuples containing session ID and session training index
            for each case to be removed.
        condition : dict, optional
            The condition map to select the cases to remove that meet all the
            provided conditions. Ignored if case_indices is specified.

            .. NOTE::
                The dictionary keys are the feature name and values are one of:

                    - None
                    - A value, must match exactly.
                    - An array of two numeric values, specifying an inclusive
                      range. Only applicable to continuous and numeric ordinal
                      features.
                    - An array of string values, must match any of these values
                      exactly. Only applicable to nominal and string ordinal
                      features.

            .. TIP::
                Example 1 - Remove all values belonging to `feature_name`::

                    condition = {"feature_name": None}

                Example 2 - Remove cases that have the value 10::

                    condition = {"feature_name": 10}

                Example 3 - Remove cases that have a value in range [10, 20]::

                    condition = {"feature_name": [10, 20]}

                Example 4 - Remove cases that match one of ['a', 'c', 'e']::

                    condition = {"feature_name": ['a', 'c', 'e']}

        condition_session : str or BaseSession, optional
            If specified, ignores the condition and operates on cases for
            the specified session id or BaseSession instance. Ignored if
            case_indices is specified.
        distribute_weight_feature : str, default None
            When specified, will distribute the removed cases' weights
            from this feature into their neighbors.
        precision : str, default None
            The precision to use when removing the cases. Options are 'exact'
            or 'similar'. If not specified "exact" will be used. Ignored if
            case_indices is specified.
        preserve_session_data : bool, default False
            When True, will remove cases without cleaning up session data.

        Returns
        -------
        int
            The number of cases removed.
        """
        if isinstance(condition_session, BaseSession):
            condition_session_id = condition_session.id
        else:
            condition_session_id = condition_session
        return self.client.remove_cases(
            trainee_id=self.id,
            num_cases=num_cases,
            case_indices=case_indices,
            condition=condition,
            condition_session=condition_session_id,
            distribute_weight_feature=distribute_weight_feature,
            precision=precision,
            preserve_session_data=preserve_session_data,
        )

    def edit_cases(
        self,
        feature_values: Union[List[object], "DataFrame"],
        *,
        case_indices: Optional[CaseIndices] = None,
        condition: Optional[Dict[str, object]] = None,
        condition_session: Optional[Union[str, BaseSession]] = None,
        features: Optional[Iterable[str]] = None,
        num_cases: Optional[int] = None,
        precision: Optional[str] = None
    ) -> int:
        """
        Edit feature values for the specified cases.

        Parameters
        ----------
        feature_values : list of object or pandas.DataFrame
            The feature values to edit the case(s) with. If specified as a list,
            the order corresponds with the order of the `features` parameter.
            If specified as a DataFrame, only the first row will be used.
        case_indices : Iterable of Sequence[str, int], optional
            An Iterable of Sequences containing the session id and index, where
            index is the original 0-based index of the case as it was trained
            into the session. This explicitly specifies the cases to edit. When
            specified, `condition` and `condition_session` are ignored.
        condition : dict or None, optional
            A condition map to select which cases to edit. Ignored when
            `case_indices` are specified.

            .. NOTE::
                The dictionary keys are the feature name and values are one of:

                    - None
                    - A value, must match exactly.
                    - An array of two numeric values, specifying an inclusive
                      range. Only applicable to continuous and numeric ordinal
                      features.
                    - An array of string values, must match any of these values
                      exactly. Only applicable to nominal and string ordinal
                      features.

        condition_session : str or BaseSession, optional
            If specified, ignores the condition and operates on all cases for
            the specified session id or BaseSession instance.
        features : list of str, optional
            The names of the features to edit. Required when `feature_values`
            is not specified as a DataFrame.
        num_cases : int, default None
            The maximum amount of cases to edit. If not specified, the limit
            will be k cases if precision is "similar", or no limit if precision
            is "exact".
        precision : str, default None
            The precision to use when removing the cases. Options are 'exact'
            or 'similar'. If not specified "exact" will be used.

        Returns
        -------
        int
            The number of cases modified.
        """
        if isinstance(condition_session, BaseSession):
            condition_session_id = condition_session.id
        else:
            condition_session_id = condition_session
        return self.client.edit_cases(
            trainee_id=self.id,
            case_indices=case_indices,
            condition=condition,
            condition_session=condition_session_id,
            features=features,
            feature_values=feature_values,
            num_cases=num_cases,
            precision=precision,
        )

    def get_sessions(self) -> List[Dict[str, str]]:
        """
        Get all session ids of the trainee.

        Returns
        -------
        list of dict
            A list of dicts with keys "id" and "name" for each session
            in the model.
        """
        return self.client.get_trainee_sessions(self.id)

    def delete_session(self, session: Union[str, BaseSession]) -> None:
        """
        Delete a session from the trainee.

        Parameters
        ----------
        session : str or BaseSession
            The id or instance of the session to remove from the model.

        Returns
        -------
        None
        """
        if isinstance(session, BaseSession):
            session_id = session.id
        else:
            session_id = session
        self.client.delete_trainee_session(trainee_id=self.id, session=session_id)

    def get_session_indices(self, session: Union[str, BaseSession]) -> "Index":
        """
        Get all session indices for a specified session.

        Parameters
        ----------
        session : str or BaseSession
            The id or instance of the session to retrieve indices for from
            the model.

        Returns
        -------
        pandas.Index
            An index of the session indices for the requested session.
        """
        if isinstance(session, BaseSession):
            session_id = session.id
        else:
            session_id = session
        return self.client.get_trainee_session_indices(
            trainee_id=self.id,
            session=session_id,
        )

    def get_session_training_indices(self, session: Union[str, BaseSession]) -> "Index":
        """
        Get all session training indices for a specified session.

        Parameters
        ----------
        session : str or BaseSession
            The id or instance of the session to retrieve training indices for
            from the model.

        Returns
        -------
        pandas.Index
            An index of the session training indices for the requested session.
        """
        if isinstance(session, BaseSession):
            session_id = session.id
        else:
            session_id = session
        return self.client.get_trainee_session_training_indices(
            trainee_id=self.id,
            session=session_id,
        )

    def get_cases(
        self,
        *,
        case_indices: Optional[CaseIndices] = None,
        features: Optional[Iterable[str]] = None,
        indicate_imputed: Optional[bool] = False,
        session: Optional[Union[str, BaseSession]] = None,
        condition: Optional[Dict] = None,
        num_cases: Optional[int] = None,
        precision: Optional[str] = None
    ) -> "DataFrame":
        """
        Get the trainee's cases.

        Parameters
        ----------
        case_indices : Iterable of Sequence[str, int], optional
            List of tuples, of session id and index, where index is the
            original 0-based index of the case as it was trained into the
            session. If specified, returns only these cases and ignores the
            session parameter.

            .. NOTE::
                If case_indices are provided, condition (and precision)
                are ignored.

        features : list of str, optional
            A list of feature names to return values for in leu of all
            default features.

            Built-in features that are available for retrieval:

                | **.session** - The session id the case was trained under.
                | **.session_training_index** - 0-based original index of the
                  case, ordered by training during the session; is never
                  changed.

        indicate_imputed : bool, default False
            If True, an additional value will be appended to the cases
            indicating if the case was imputed.

        session : str or BaseSession, optional
            The id or instance of the session to retrieve training indices for
            from the model.

            .. NOTE::
                If a session is not provided, the order of the cases is not
                guaranteed to be the same as the order they were trained into
                the model.

        condition : dict, optional
            The condition map to select the cases to retrieve that meet all the
            provided conditions.

            .. NOTE::
                The dictionary keys are the feature name and values are one of:

                    - None
                    - A value, must match exactly.
                    - An array of two numeric values, specifying an inclusive
                      range. Only applicable to continuous and numeric ordinal
                      features.
                    - An array of string values, must match any of these values
                      exactly. Only applicable to nominal and string ordinal
                      features.

            .. NOTE::
                This option will be ignored if case_indices is supplied.

            .. TIP::
                Example 1 - Retrieve all values belonging to `feature_name`::

                    criteria = {"feature_name": None}

                Example 2 - Retrieve cases that have the value 10::

                    criteria = {"feature_name": 10}

                Example 3 - Retrieve cases that have a value in range [10, 20]::

                    criteria = {"feature_name": [10, 20]}

                Example 4 - Retrieve cases that match one of ['a', 'c', 'e']::

                    condition = {"feature_name": ['a', 'c', 'e']}

                Example 5 - Retrieve cases using session name and index::

                    criteria = {'.session':'your_session_name',
                                '.session_training_index': 1}

        num_cases : int, default None
            The maximum amount of cases to retrieve. If not specified, the limit
            will be k cases if precision is "similar", or no limit if precision
            is "exact".
        precision : str, default None
            The precision to use when retrieving the cases via condition.
            Options are 'exact' or 'similar'. If not specified, "exact" will
            be used.

        Returns
        -------
        pandas.DataFrame
            The trainee's cases.
        """
        if isinstance(session, BaseSession):
            session_id = session.id
        else:
            session_id = session
        return self.client.get_cases(
            trainee_id=self.id,
            features=features,
            case_indices=case_indices,
            indicate_imputed=indicate_imputed,
            session=session_id,
            condition=condition,
            num_cases=num_cases,
            precision=precision,
        )

    def get_extreme_cases(
        self,
        *,
        features: Optional[Iterable[str]] = None,
        num: int,
        sort_feature: str,
    ) -> "DataFrame":
        """
        Get the trainee's extreme cases.

        Parameters
        ----------
        features : list of str, optional
            The features to include in the case data.
        num : int
            The number of cases to get.
        sort_feature
            The name of the feature by which extreme cases are sorted.

        Returns
        -------
        pandas.DataFrame
            The trainee's extreme cases.
        """
        return self.client.get_extreme_cases(
            trainee_id=self.id, features=features, num=num, sort_feature=sort_feature
        )

    def get_num_training_cases(self) -> int:
        """
        Return the number of trained cases for the trainee.

        Returns
        -------
        int
            The number of trained cases.
        """
        return self.client.get_num_training_cases(self.id)

    def add_feature(
        self,
        feature: str,
        feature_value: Optional[object] = None,
        *,
        condition: Optional[Dict[str, object]] = None,
        condition_session: Optional[Union[str, BaseSession]] = None,
        feature_attributes: Optional[Dict] = None,
        overwrite: Optional[bool] = False,
    ) -> None:
        """
        Add a feature to the model.

        Parameters
        ----------
        feature : str
            The name of the feature.
        feature_attributes : dict, optional
            The dict of feature specific attributes for this feature. If
            unspecified and conditions are not specified, will assume feature
            type as 'continuous'.
        feature_value : int or float or str, optional
            The value to populate the feature with.
            By default, populates the new feature with None.
        condition : dict, optional
            A condition map where feature values will only be added when
            certain criteria is met.

            If None, the feature will be added to all cases in the model
            and feature metadata will be updated to include it. If specified
            as an empty dict, the feature will still be added to all cases in
            the model but the feature metadata will not be updated.

            .. NOTE::
                The dictionary keys are the feature name and values are one of:

                    - None
                    - A value, must match exactly.
                    - An array of two numeric values, specifying an inclusive
                      range. Only applicable to continuous and numeric ordinal
                      features.
                    - An array of string values, must match any of these values
                      exactly. Only applicable to nominal and string ordinal
                      features.

            .. TIP::
                For instance to add the `feature_value` only when the
                `length` and `width` features are equal to 10::

                    condition = {"length": 10, "width": 10}

        condition_session : str or BaseSession, optional
            If specified, ignores the condition and operates on cases for the
            specified session id or BaseSession instance.
        overwrite : bool, default False
            If True, the feature will be over-written if it exists.
        """
        if isinstance(condition_session, BaseSession):
            condition_session_id = condition_session.id
        else:
            condition_session_id = condition_session
        self.client.add_feature(
            trainee_id=self.id,
            condition=condition,
            condition_session=condition_session_id,
            feature=feature,
            feature_value=feature_value,
            feature_attributes=feature_attributes,
            overwrite=overwrite,
        )
        self._features = self.client.trainee_cache.get(self.id).features

    def remove_feature(
        self,
        feature: str,
        *,
        condition: Optional[Dict[str, object]] = None,
        condition_session: Optional[Union[str, BaseSession]] = None,
    ) -> None:
        """
        Remove a feature from the trainee.

        Parameters
        ----------
        feature : str
            The name of the feature to remove.
        condition : dict, default None
            A condition map where features will only be removed when certain
            criteria is met.

            If None, the feature will be removed from all cases in the model
            and feature metadata will be updated to exclude it. If specified
            as an empty dict, the feature will still be removed from all cases
            in the model but the feature metadata will not be updated.

            .. NOTE::
                The dictionary keys are the feature name and values are one of:

                    - None
                    - A value, must match exactly.
                    - An array of two numeric values, specifying an inclusive
                      range. Only applicable to continuous and numeric ordinal
                      features.
                    - An array of string values, must match any of these values
                      exactly. Only applicable to nominal and string ordinal
                      features.

            .. TIP::
                For instance to remove the `length` feature only when the
                value is between 1 and 5::

                    condition = {"length": [1, 5]}

        condition_session : str or BaseSession, optional
            If specified, ignores the condition and operates on cases for the
            specified session id or BaseSession instance.
        """
        if isinstance(condition_session, BaseSession):
            condition_session_id = condition_session.id
        else:
            condition_session_id = condition_session
        self.client.remove_feature(
            trainee_id=self.id,
            condition=condition,
            condition_session=condition_session_id,
            feature=feature,
        )
        self._features = self.client.trainee_cache.get(self.id).features

    def remove_series_store(self, series: Optional[str] = None) -> None:
        """
        Clear stored series from trainee.

        Parameters
        ----------
        series : str, optional
            Series id to clear. If not provided, clears the entire
            series store for the trainee.

        Returns
        -------
        None
        """
        self.client.remove_series_store(trainee_id=self.id, series=series)

    def append_to_series_store(
        self,
        series: str,
        contexts: Union[List[List[object]], "DataFrame"],
        *,
        context_features: Optional[Iterable[str]] = None,
    ) -> None:
        """
        Append the specified contexts to a series store.

        For use with train series.

        Parameters
        ----------
        series : str
            The name of the series store to append to.
        contexts : list of list of object or pandas.DataFrame
            The list of context values to append to the series.
        context_features : list of str, optional
            The list of feature names for contexts.

        Returns
        -------
        None
        """
        self.client.append_to_series_store(
            trainee_id=self.id,
            series=series,
            contexts=contexts,
            context_features=context_features,
        )

    def set_substitute_feature_values(
        self, substitution_value_map: Dict[str, Dict[str, Any]]
    ) -> None:
        """
        Set a substitution map for use in extended nominal generation.

        Parameters
        ----------
        substitution_value_map : dict
            A dictionary of feature name to a dictionary of feature value to
            substitute feature value.

            If this dict is None, all substitutions will be disabled and
            cleared. If any feature in the `substitution_value_map` has
            features mapping to `None` or `{}`, substitution values will
            immediately be generated.

        Returns
        -------
        None
        """
        self.client.set_substitute_feature_values(
            trainee_id=self.id, substitution_value_map=substitution_value_map
        )

    def get_substitute_feature_values(
        self, *, clear_on_get: Optional[bool] = True
    ) -> Dict[str, Dict[str, Any]]:
        """
        Get a substitution map for use in extended nominal generation.

        Parameters
        ----------
        clear_on_get : bool, default True
            Clears the substitution values map in the trainee upon retrieving
            them. This is done if it is desired to prevent the substitution
            map from being persisted. If set to False, the model will not be
            cleared which preserves substitution mappings if the model is
            saved; representing a potential privacy leak should the
            substitution map be made public.

        Returns
        -------
        dict of {str: dict}
            A dictionary of feature name to a dictionary of feature value to
            substitute feature value.
        """
        return self.client.get_substitute_feature_values(
            trainee_id=self.id, clear_on_get=clear_on_get
        )

    def react_group(
        self,
        *,
        distance_contributions: Optional[bool] = False,
        familiarity_conviction_addition: Optional[bool] = True,
        familiarity_conviction_removal: Optional[bool] = False,
        features: Optional[Iterable[str]] = None,
        new_cases: Optional[
            Union[List["DataFrame"], List[List[List[object]]]]] = None,
        kl_divergence_addition: Optional[bool] = False,
        kl_divergence_removal: Optional[bool] = False,
        p_value_of_addition: Optional[bool] = False,
        p_value_of_removal: Optional[bool] = False,
        trainees_to_compare: Optional[Iterable[Union["Trainee", str]]] = None,
        use_case_weights: Optional[bool] = False,
        weight_feature: Optional[str] = None,
    ) -> "DataFrame":
        """
        Computes specified data for a **set** of cases.

        Return the list of familiarity convictions (and optionally, distance
        contributions or p values) for each set.

        Parameters
        ----------
        distance_contributions : bool, default False
            (Optional) Calculate and output distance contribution ratios in
            the output dict for each case.
        familiarity_conviction_addition : bool, default True
            (Optional) Calculate and output familiarity conviction of adding the
            specified cases.
        familiarity_conviction_removal : bool, default False
            (Optional) Calculate and output familiarity conviction of removing
            the specified cases.
        features : list of str or None, optional
            A list of feature names to consider while calculating convictions.
        kl_divergence_addition : bool, default False
            (Optional) Calculate and output KL divergence of adding the
            specified cases.
        kl_divergence_removal : bool, default False
            (Optional) Calculate and output KL divergence of removing the
            specified cases.
        new_cases : list of list of list of object or list of pandas.DataFrame, optional
            Specify a **set** using a list of cases to compute the conviction
            of groups of cases as shown in the following example.

            Example::

                new_cases = [
                    [[1, 2, 3], [4, 5, 6], [7, 8, 9]], # Group 1
                    [[1, 2, 3]], # Group 2
                ]

        p_value_of_addition : bool, default False
            (Optional) If true will output p value of addition.
        p_value_of_removal : bool, default False
            (Optional) If true will output p value of removal.
        trainees_to_compare : list of (str or Trainee), optional
            (Optional) If specified ignores the 'new_cases' parameter and uses
            cases from the specified trainee(s) instead. Values should be either
            the trainee object or its ID (trainee name is not supported).
        use_case_weights : bool, default False
            When True, will scale influence weights by each case's
            `weight_feature` weight.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.

        Returns
        -------
        pandas.DataFrame
            The conviction of grouped cases.
        """
        if trainees_to_compare:
            serialized_trainees_to_compare = [
                t.id if isinstance(t, BaseTrainee) else t
                for t in trainees_to_compare
            ]
        else:
            serialized_trainees_to_compare = None

        return self.client.react_group(
            trainee_id=self.id,
            new_cases=new_cases,
            features=features,
            trainees_to_compare=serialized_trainees_to_compare,
            familiarity_conviction_addition=familiarity_conviction_addition,
            familiarity_conviction_removal=familiarity_conviction_removal,
            kl_divergence_addition=kl_divergence_addition,
            kl_divergence_removal=kl_divergence_removal,
            p_value_of_addition=p_value_of_addition,
            p_value_of_removal=p_value_of_removal,
            distance_contributions=distance_contributions,
            use_case_weights=use_case_weights,
            weight_feature=weight_feature,
        )

    def get_feature_conviction(
        self,
        *,
        action_features: Optional[Iterable[str]] = None,
        familiarity_conviction_addition: Optional[bool] = True,
        familiarity_conviction_removal: Optional[bool] = False,
        features: Optional[Iterable[str]] = None,
        use_case_weights: Optional[bool] = False,
        weight_feature: Optional[str] = None,
    ) -> "DataFrame":
        """
        Get familiarity conviction for features in the model.

        Parameters
        ----------
        action_features : list of str, optional
            The feature names to be treated as action features during
            conviction calculation in order to determine the conviction
            of each feature against the set of action_features. If not
            specified, conviction is computed for each feature against the
            rest of the features as a whole.
        familiarity_conviction_addition : bool, default True
            (Optional) Calculate and output familiarity conviction of adding the
            specified cases.
        familiarity_conviction_removal : bool, default False
            (Optional) Calculate and output familiarity conviction of removing
            the specified cases.
        features : list of str, optional
            The feature names to calculate convictions for. At least 2 features
            are required to get familiarity conviction. If not specified all
            features will be used.
        use_case_weights : bool, default False
            When True, will scale influence weights by each case's
            `weight_feature` weight.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.

        Returns
        -------
        pandas.DataFrame
            A DataFrame containing the familiarity conviction rows to feature
            columns.
        """
        return self.client.get_feature_conviction(
            trainee_id=self.id,
            action_features=action_features,
            familiarity_conviction_addition=familiarity_conviction_addition,
            familiarity_conviction_removal=familiarity_conviction_removal,
            features=features,
            use_case_weights=use_case_weights,
            weight_feature=weight_feature,
        )

    def get_feature_residuals(
        self,
        *,
        action_feature: Optional[str] = None,
        robust: Optional[bool] = None,
        robust_hyperparameters: Optional[bool] = None,
        weight_feature: Optional[str] = None,
    ) -> "DataFrame":
        """
        Get cached feature residuals.

        All keyword arguments are optional, when not specified will auto-select
        cached residuals for output, when specified will attempt to
        output the cached residuals best matching the requested parameters,
        or None if no cached match is found.

        .. deprecated:: 1.0.0
            Use :meth:`Trainee.get_prediction_stats` instead.

        Parameters
        ----------
        action_feature : str, optional
            When specified, will attempt to return residuals that
            were computed for this specified action_feature.
            Note: ".targetless" is the action feature used during targetless
            analysis.
        robust : bool, optional
            When specified, will attempt to return residuals that
            were computed with the specified robust or non-robust type.
        robust_hyperparameters : bool, optional
            When specified, will attempt to return residuals that were
            computed using hyperpparameters with the specified robust or
            non-robust type.
        weight_feature : str, optional
            When specified, will attempt to return residuals that
            were computed using this weight_feature.

        Returns
        -------
        pandas.DataFrame
            The feature residuals.
        """
        return self.client.get_feature_residuals(
            trainee_id=self.id,
            action_feature=action_feature,
            robust=robust,
            robust_hyperparameters=robust_hyperparameters,
            weight_feature=weight_feature,
        )

    def get_prediction_stats(
        self,
        *,
        action_feature: Optional[str] = None,
        condition: Optional[Dict[str, Any]] = None,
        num_cases: Optional[int] = None,
        num_robust_influence_samples_per_case: Optional[int] = None,
        precision: Optional[str] = None,
        robust: Optional[bool] = None,
        robust_hyperparameters: Optional[bool] = None,
        stats: Optional[Iterable[str]] = None,
        weight_feature: Optional[str] = None,
    ) -> "DataFrame":
        """
        Get feature prediction stats.

        Gets cached stats when condition is None.
        If condition is not None, then uses the condition to select cases and
        computes prediction stats for that set of cases.

        All keyword arguments are optional, when not specified will auto-select
        all cached stats for output, when specified will attempt to
        output the cached stats best matching the requested parameters,
        or None if no cached match is found.

        Parameters
        ----------
        action_feature : str, optional
            When specified, will attempt to return stats that
            were computed for this specified action_feature.
            Note: ".targetless" is the action feature used during targetless
            analysis.

            .. NOTE::
                If get_prediction_stats is being used with time series analysis,
                the action feature for which the prediction statistics information
                is desired must be specified.
        condition : dict or None, optional
            A condition map to select which cases to compute prediction stats
            for.

            .. NOTE::
                The dictionary keys are the feature name and values are one of:

                    - None
                    - A value, must match exactly.
                    - An array of two numeric values, specifying an inclusive
                      range. Only applicable to continuous and numeric ordinal
                      features.
                    - An array of string values, must match any of these values
                      exactly. Only applicable to nominal and string ordinal
                      features.
        num_cases : int, default None
            The maximum amount of cases to use to calculate prediction stats.
            If not specified, the limit will be k cases if precision is
            "similar", or 1000 cases if precision is "exact". Only used if
            `condition` is not None.
        num_robust_influence_samples_per_case : int, optional
            Specifies the number of robust samples to use for each case for
            robust contribution computations.
            Defaults to 300 + 2 * (number of features).
        precision : str, default None
            The precision to use when selecting cases with the condition.
            Options are 'exact' or 'similar'. If not specified "exact" will be
            used. Only used if `condition` is not None.
        robust : bool, optional
            When specified, will attempt to return stats that
            were computed with the specified robust or non-robust type.
        robust_hyperparameters : bool, optional
            When specified, will attempt to return stats that were
            computed using hyperparameters with the specified robust or
            non-robust type.
        stats : list of str, optional
            List of stats to output. When unspecified, returns all.
            Allowed values:

                - accuracy : The number of correct predictions divided by the
                  total number of predictions.
                - confusion_matrix : A map of actual feature value to a map of
                  predicted feature value to counts.
                - contribution : Feature contributions to predicted value when
                  each feature is dropped from the model, applies to all
                  features.
                - mae : Mean absolute error. For continuous features, this is
                  calculated as the mean of absolute values of the difference
                  between the actual and predicted values. For nominal features,
                  this is 1 - the average categorical action probability of each case's
                  correct classes. Categorical action probabilities are the probabilities
                  for each class for the action feature.
                - mda : Mean decrease in accuracy when each feature is dropped
                  from the model, applies to all features.
                - mda_permutation : Mean decrease in accuracy that used
                  scrambling of feature values instead of dropping each
                  feature, applies to all features.
                - precision : Precision (positive predictive) value for nominal
                  features only.
                - r2 : The r-squared coefficient of determination, for
                  continuous features only.
                - recall : Recall (sensitivity) value for nominal features only.
                - rmse : Root mean squared error, for continuous features only.
                - spearman_coeff : Spearman's rank correlation coefficient,
                  for continuous features only.

        weight_feature : str, optional
            When specified, will attempt to return stats that
            were computed using this weight_feature.

        Returns
        -------
        DataFrame
            A DataFrame of feature name columns to stat value rows. Indexed
            by the stat type.
        """
        return self.client.get_prediction_stats(
            trainee_id=self.id,
            action_feature=action_feature,
            robust=robust,
            robust_hyperparameters=robust_hyperparameters,
            stats=stats,
            weight_feature=weight_feature,
            condition=condition,
            precision=precision,
            num_cases=num_cases,
            num_robust_influence_samples_per_case=num_robust_influence_samples_per_case,
        )

    def get_marginal_stats(
        self, *,
        condition: Optional[Dict[str, Any]] = None,
        num_cases: Optional[int] = None,
        precision: Optional[Literal["exact", "similar"]] = None,
        weight_feature: Optional[str] = None,
    ) -> "DataFrame":
        """
        Get marginal stats for all features.

        Parameters
        ----------
        condition : dict or None, optional
            A condition map to select which cases to compute marginal stats
            for.

            .. NOTE::
                The dictionary keys are the feature name and values are one of:

                    - None
                    - A value, must match exactly.
                    - An array of two numeric values, specifying an inclusive
                      range. Only applicable to continuous and numeric ordinal
                      features.
                    - An array of string values, must match any of these values
                      exactly. Only applicable to nominal and string ordinal
                      features.
        num_cases : int, default None
            The maximum amount of cases to use to calculate marginal stats.
            If not specified, the limit will be k cases if precision is
            "similar". Only used if `condition` is not None.
        precision : str, default None
            The precision to use when selecting cases with the condition.
            Options are 'exact' or 'similar'. If not specified "exact" will be
            used. Only used if `condition` is not None.
        weight_feature : str, optional
            When specified, will attempt to return stats that were computed
            using this weight_feature.

        Returns
        -------
        DataFrame
            A DataFrame of feature name columns to stat value rows. Indexed
            by the stat type.
        """
        return self.client.get_marginal_stats(
            trainee_id=self.id,
            condition=condition,
            num_cases=num_cases,
            precision=precision,
            weight_feature=weight_feature
        )

    def react_into_features(
        self,
        *,
        distance_contribution: Optional[Union[str, bool]] = False,
        familiarity_conviction_addition: Optional[Union[str, bool]] = False,
        familiarity_conviction_removal: Optional[Union[str, bool]] = False,
        features: Optional[Iterable[str]] = None,
        p_value_of_addition: Optional[Union[str, bool]] = False,
        p_value_of_removal: Optional[Union[str, bool]] = False,
        similarity_conviction: Optional[Union[str, bool]] = False,
        use_case_weights: Optional[bool] = False,
        weight_feature: Optional[str] = None,
    ) -> None:
        """
        Calculate conviction and other data and stores them into features.

        Parameters
        ----------
        distance_contribution : bool or str, default False
            The name of the feature to store distance contribution.
            If set to True the values will be stored to the feature
            'distance_contribution'.
        familiarity_conviction_addition : bool or str, default False
            (Optional) The name of the feature to store conviction of addition
            values. If set to True the values will be stored to the feature
            'familiarity_conviction_addition'.
        familiarity_conviction_removal : bool or str, default False
            (Optional) The name of the feature to store conviction of removal
            values. If set to True the values will be stored to the feature
            'familiarity_conviction_removal'.
        features : list of str, optional
            A list of features to calculate convictions.
        p_value_of_addition : bool or str, default False
            (Optional) The name of the feature to store p value of addition
            values. If set to True the values will be stored to the feature
            'p_value_of_addition'.
        p_value_of_removal : bool or str, default False
            (Optional) The name of the feature to store p value of removal
            values. If set to True the values will be stored to the feature
            'p_value_of_removal'.
        similarity_conviction : bool or str, default False
            (Optional) The name of the feature to store similarity conviction
            values. If set to True the values will be stored to the feature
            'similarity_conviction'.
        use_case_weights : bool, default False
            When True, will scale influence weights by each case's
            `weight_feature` weight.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.

        Returns
        -------
        None
        """
        self.client.react_into_features(
            trainee_id=self.id,
            distance_contribution=distance_contribution,
            familiarity_conviction_addition=familiarity_conviction_addition,
            familiarity_conviction_removal=familiarity_conviction_removal,
            p_value_of_addition=p_value_of_addition,
            p_value_of_removal=p_value_of_removal,
            similarity_conviction=similarity_conviction,
            features=features,
            use_case_weights=use_case_weights,
            weight_feature=weight_feature,
        )

    def react_into_trainee(
        self,
        *,
        action_feature: Optional[str] = None,
        context_features: Optional[Iterable[str]] = None,
        contributions: Optional[bool] = None,
        contributions_robust: Optional[bool] = None,
        hyperparameter_param_path: Optional[Iterable[str]] = None,
        mda: Optional[bool] = None,
        mda_permutation: Optional[bool] = None,
        mda_robust: Optional[bool] = None,
        mda_robust_permutation: Optional[bool] = None,
        num_robust_influence_samples=None,
        num_robust_residual_samples=None,
        num_robust_influence_samples_per_case=None,
        num_samples: Optional[int] = None,
        residuals: Optional[bool] = None,
        residuals_robust: Optional[bool] = None,
        sample_model_fraction: Optional[float] = None,
        sub_model_size: Optional[int] = None,
        use_case_weights: Optional[bool] = False,
        weight_feature: Optional[str] = None,
    ) -> None:
        """
        Compute and cache specified feature interpretations.

        Parameters
        ----------
        action_feature : str, optional
            Name of target feature whose hyperparameters to use
            for computations.  Default is whatever the model was analyzed for,
            or the mda_action_features for MDA, or ".targetless" if analyzed
            for targetless.
        context_features : list of str, optional
            List of features names to use as contexts for
            computations. Default is all trained non-unique features if
            unspecified.
        contributions : bool, optional
            For each context_feature, use the full set of all other
            context_features to compute the mean absolute delta between
            prediction of action_feature with and without the context_feature
            in the model. False removes cached values.
        contributions_robust : bool, optional
            For each context_feature, use the robust (power set/permutation)
            set of all other context_features to compute the mean absolute
            delta between prediction of action_feature with and without the
            context_feature in the model. False removes cached values.
        hyperparameter_param_path : list of str, optional.
            Full path for hyperparameters to use for computation. If specified
            for any residual computations, takes precendence over action_feature
            parameter.  Can be set to a 'paramPath' value from the results of
            'get_params()' for a specific set of hyperparameters.
        mda : bool, optional
            When True will compute Mean Decrease in Accuracy (MDA)
            for each context feature at predicting mda_action_features. Drop
            each feature and use the full set of remaining context features
            for each prediction. False removes cached values.
        mda_permutation : bool, optional
            Compute MDA by scrambling each feature and using the
            full set of remaining context features for each prediction.
            False removes cached values.
        mda_robust : bool, optional
            Compute MDA by dropping each feature and using the
            robust (power set/permutations) set of remaining context features
            for each prediction. False removes cached values.
        mda_robust_permutation : bool, optional
            Compute MDA by scrambling each feature and using the
            robust (power set/permutations) set of remaining context features
            for each prediction. False removes cached values.
        num_robust_influence_samples : int, optional
            Total sample size of model to use (using sampling with replacement)
            for robust contribution computation. Defaults to 300.
        num_robust_residual_samples : int, optional
            Total sample size of model to use (using sampling with replacement)
            for robust mda and residual computation.
            Defaults to 1000 * (1 + log(number of features)).  Note: robust mda
            will be updated to use num_robust_influence_samples in a future release.
        num_robust_influence_samples_per_case : int, optional
            Specifies the number of robust samples to use for each case for
            robust contribution computations.
            Defaults to 300 + 2 * (number of features).
        num_samples : int, optional
            Total sample size of model to use (using sampling with replacement)
            for all non-robust computation. Defaults to 1000.
            If specified overrides sample_model_fraction.```
        residuals : bool, optional
            For each context_feature, use the full
            set of all other context_features to predict the feature.
            False removes cached values.
        residuals_robust : bool, optional
            For each context_feature, use the robust (power
            set/permutations) set of all other context_features to predict the
            feature.  False removes cached values.
        sample_model_fraction : float, optional
            A value between 0.0 - 1.0, percent of model to use in sampling
            (using sampling without replacement). Applicable only to non-robust
            computation. Ignored if num_samples is specified.
            Higher values provide better accuracy at the cost of compute time.
        sub_model_size : int, optional
            Subset of model to use for calculations. Applicable only
            to models > 1000 cases.
        use_case_weights : bool, default False
            If set to True will scale influence weights by each case's
            weight_feature weight.
        weight_feature : str, optional
            The name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.

        Returns
        -------
        None
        """
        self.client.react_into_trainee(
            trainee_id=self.id,
            action_feature=action_feature,
            context_features=context_features,
            contributions=contributions,
            contributions_robust=contributions_robust,
            hyperparameter_param_path=hyperparameter_param_path,
            mda=mda,
            mda_permutation=mda_permutation,
            mda_robust=mda_robust,
            mda_robust_permutation=mda_robust_permutation,
            num_robust_influence_samples=num_robust_influence_samples,
            num_robust_residual_samples=num_robust_residual_samples,
            num_robust_influence_samples_per_case=num_robust_influence_samples_per_case,
            num_samples=num_samples,
            residuals=residuals,
            residuals_robust=residuals_robust,
            sample_model_fraction=sample_model_fraction,
            sub_model_size=sub_model_size,
            use_case_weights=use_case_weights,
            weight_feature=weight_feature,
        )

    def get_feature_mda(
        self,
        action_feature: str,
        *,
        permutation: Optional[bool] = None,
        robust: Optional[bool] = None,
        weight_feature: Optional[str] = None,
    ) -> "DataFrame":
        """
        Get cached feature Mean Decrease In Accuracy (MDA).

        All keyword arguments are optional, when not specified will auto-select
        cached MDA for output, when specified will attempt to
        output the cached MDA best matching the requested parameters,
        or None if no cached match is found.

        .. deprecated:: 1.0.0
            Use :meth:`Trainee.get_prediction_stats` instead.

        Parameters
        ----------
        action_feature : str
            Will attempt to return MDA that was
            computed for this specified action_feature.
        permutation : bool, optional
            When False, will attempt to return MDA that was computed
            by dropping each feature. When True will attempt to return MDA that
            was computed with permutations by scrambling each feature.
        robust : bool, optional
            When specified, will attempt to return MDA that was
            computed with the specified robust or non-robust type.
        weight_feature : str, optional
            When specified, will attempt to return MDA that was
            computed using this weight_feature.

        Returns
        -------
        pandas.DataFrame
            The mean decrease in accuracy values for context features.
        """
        return self.client.get_feature_mda(
            trainee_id=self.id,
            action_feature=action_feature,
            permutation=permutation,
            robust=robust,
            weight_feature=weight_feature,
        )

    def get_feature_contributions(
        self,
        action_feature: str,
        *,
        robust: Optional[bool] = None,
        directional: bool = False,
        weight_feature: Optional[str] = None,
    ) -> "DataFrame":
        """
        Get cached feature contributions.

        All keyword arguments are optional, when not specified will auto-select
        cached contributions for output, when specified will attempt to
        output the cached contributions best matching the requested parameters,
        or None if no cached match is found.

        .. deprecated:: 1.0.0
            Use :meth:`Trainee.get_prediction_stats` instead.

        Parameters
        ----------
        action_feature : str
            Will attempt to return contributions that were
            computed for this specified action_feature.
        robust : bool, optional
            When specified, will attempt to return contributions that were
            computed with the specified robust or non-robust type.
        directional : bool, default False
            If false returns absolute feature contributions. If true, returns
            directional feature contributions.
        weight_feature : str, optional
            When specified, will attempt to return contributions that were
            computed using this weight_feature.

        Returns
        -------
        pandas.DataFrame
            The contribution values for context features.
        """
        return self.client.get_feature_contributions(
            trainee_id=self.id,
            action_feature=action_feature,
            robust=robust,
            directional=directional,
            weight_feature=weight_feature,
        )

    def get_params(
        self,
        *,
        action_feature: Optional[str] = None,
        context_features: Optional[Iterable[str]] = None,
        mode: Optional[Literal["robust", "full"]] = None,
        weight_feature: Optional[str] = None,
    ) -> Dict[str, Any]:
        """
        Get the parameters used by the Trainee. If 'action_feature',
        'context_features', 'mode', or 'weight_feature' are specified, then
        the best hyperparameters analyzed in the Trainee are the value of the
        'hyperparameter_map' key, otherwise this value will be the dictionary
        containing all the hyperparameter sets in the Trainee.


        Parameters
        ----------
        action_feature : str, optional
            If specified will return the best analyzed hyperparameters to
            target this feature.
        context_features : str, optional
            If specified, will find and return the best analyzed hyperparameters
            to use with these context features.
        mode : str, optional
            If specified, will find and return the best analyzed hyperparameters
            that were computed in this mode.
        weight_feature : str, optional
            If specified, will find and return the best analyzed hyperparameters
            that were analyzed using this weight feaure.

        Returns
        -------
        dict
            A dict including the either all of the Trainee's internal
            parameters or only the best hyperparameters selected using the
            passed parameters.
        """
        return self.client.get_params(
            self.id,
            action_feature=action_feature,
            context_features=context_features,
            mode=mode,
            weight_feature=weight_feature,
        )

    def set_params(self, params: Dict[str, Any]) -> None:
        """
        Set the workflow attributes for the trainee.

        Parameters
        ----------
        params : dict
            A dictionary in the following format containing the hyperparameter
            information, which is required, and other parameters which are
            all optional.

            Example::

                {
                    "hyperparameter_map": {
                        ".targetless": {
                            "robust": {
                                ".none": {
                                    "dt": -1, "p": .1, "k": 8
                                }
                            }
                        }
                    },
                    "auto_analyze_enabled": False,
                    "analyze_threshold": 100,
                    "analyze_growth_factor": 7.389,
                    "auto_analyze_limit_size": 100000
                }
        """
        self.client.set_params(self.id, params=params)

    @property
    def client(self) -> Union[AbstractHowsoClient, HowsoPandasClientMixin]:
        """
        The client instance used by the trainee.

        Returns
        -------
        AbstractHowsoClient
            The client instance.
        """
        return self._client

    @client.setter
    def client(self, client: AbstractHowsoClient) -> None:
        """
        Set the client instance used by the trainee.

        Parameters
        ----------
        client : AbstractHowsoClient
            The client instance. Must be a subclass of AbstractHowsoClient
            and HowsoPandasClientMixin.

        Returns
        -------
        None
        """
        if not isinstance(client, AbstractHowsoClient):
            raise HowsoError(
                "`client` must be a subclass of AbstractHowsoClient"
            )
        if not isinstance(client, HowsoPandasClientMixin):
            raise HowsoError("`client` must be a HowsoPandasClient")
        self._client = client

    def _update_attributes(self, trainee: BaseTrainee) -> None:
        """
        Update the protected attributes of the trainee.

        Parameters
        ----------
        trainee : BaseTrainee
            The base trainee instance.

        Returns
        -------
        None
        """
        for key in self.attribute_map.keys():
            # Update the protected attributes directly since the values
            # have already been validated by the "BaseTrainee" instance
            # and to prevent triggering an API update call
            setattr(self, f"_{key}", getattr(trainee, key))

    def update(self) -> None:
        """
        Update the remote trainee with local state.

        Returns
        -------
        None
        """
        if (
            getattr(self, "id", None)
            and getattr(self, "_created", False)
            and not getattr(self, "_updating", False)
        ):
            # Only update for trainees that have been created
            try:
                self._updating = True
                trainee = BaseTrainee(**self.to_dict())
                updated_trainee = self.client.update_trainee(trainee)
                self._update_attributes(updated_trainee)
            finally:
                self._updating = False

    def get_pairwise_distances(
        self,
        features: Optional[Dict[str, Dict]] = None,
        *,
        action_feature: Optional[str] = None,
        from_case_indices: Optional[CaseIndices] = None,
        from_values: Optional[Union[List[List[object]], "DataFrame"]] = None,
        to_case_indices: Optional[CaseIndices] = None,
        to_values: Optional[Union[List[List[object]], "DataFrame"]] = None,
        use_case_weights: Optional[bool] = False,
        weight_feature: Optional[str] = None,
    ) -> List[float]:
        """
        Computes pairwise distances between specified cases.

        Returns a list of computed distances between each respective pair of
        cases specified in either `from_values` or `from_case_indices` to
        `to_values` or `to_case_indices`. If only one case is specified in any
        of the lists, all respective distances are computed to/from that one
        case.

        .. NOTE::
            - One of `from_values` or `from_case_indices` must be specified,
              not both.
            - One of `to_values` or `to_case_indices` must be specified,
              not both.

        Parameters
        ----------
        features : list of str, optional
            List of feature names to use when computing pairwise distances.
            If unspecified uses all features.
        action_feature : str, optional
            The action feature. If specified, uses targeted hyperparameters
            used to predict this `action_feature`, otherwise uses targetless
            hyperparameters.
        from_case_indices : Iterable of Sequence[str, int], optional
            An Iterable of Sequences, of session id and index, where index
            is the original 0-based index of the case as it was trained into
            the session. If specified must be either length of 1 or match
            length of `to_values` or `to_case_indices`.
        from_values : list of list of object or pandas.DataFrame, optional
            A 2d-list of case values. If specified must be either length of
            1 or match length of `to_values` or `to_case_indices`.
        to_case_indices : Iterable of Sequence[str, int], optional
            An Iterable of Sequences, of session id and index, where index
            is the original 0-based index of the case as it was trained into
            the session. If specified must be either length of 1 or match
            length of `from_values` or `from_case_indices`.
        to_values : list of list of object or pandas.DataFrame, optional
            A 2d-list of case values. If specified must be either length of
            1 or match length of `from_values` or `from_case_indices`.
        use_case_weights : bool, default False
            If set to True, will scale influence weights by each case's
            `weight_feature` weight.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.

        Returns
        -------
        list
            A list of computed pairwise distances between each corresponding
            pair of cases in `from_case_indices` and `to_case_indices`.
        """
        return self.client.get_pairwise_distances(
            self.id,
            features=features,
            action_feature=action_feature,
            from_case_indices=from_case_indices,
            from_values=from_values,
            to_case_indices=to_case_indices,
            to_values=to_values,
            use_case_weights=use_case_weights,
            weight_feature=weight_feature
        )

    def get_distances(
        self,
        features: Optional[Dict[str, Dict]] = None,
        *,
        action_feature: Optional[str] = None,
        case_indices: Optional[CaseIndices] = None,
        feature_values: Optional[Union[List[object], "DataFrame"]] = None,
        use_case_weights: Optional[bool] = False,
        weight_feature: Optional[str] = None
    ) -> "Distances":
        """
        Computes distances matrix for specified cases.

        Returns a dict with computed distances between all cases
        specified in `case_indices` or from all cases in local model as defined
        by `feature_values`.

        Parameters
        ----------
        features : list of str, optional
            List of feature names to use when computing distances. If
            unspecified uses all features.
        action_feature : str, optional
            The action feature. If specified, uses targeted hyperparameters
            used to predict this `action_feature`, otherwise uses targetless
            hyperparameters.
        case_indices : Iterable of Sequence[str, int], optional
            List of tuples, of session id and index, where index is the
            original 0-based index of the case as it was trained into the
            session. If specified, returns distances for all of these
            cases. Ignored if `feature_values` is provided. If neither
            `feature_values` nor `case_indices` is specified, uses full dataset.
        feature_values : list of object or pandas.DataFrame, optional
            If specified, returns distances of the local model relative to
            these values, ignores `case_indices` parameter. If provided a
            DataFrame, only the first row will be used.
        use_case_weights : bool, default False
            If set to True, will scale influence weights by each case's
            `weight_feature` weight.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.

        Returns
        -------
        dict
            A dict containing a matrix of computed distances and the list of
            corresponding case indices in the following format::

                {
                    'case_indices': [ session-indices ],
                    'distances': DataFrame( distances )
                }
        """
        return self.client.get_distances(
            self.id,
            features=features,
            action_feature=action_feature,
            case_indices=case_indices,
            feature_values=feature_values,
            weight_feature=weight_feature,
            use_case_weights=use_case_weights
        )

    def evaluate(
        self,
        features_to_code_map,
        *,
        aggregation_code=None,
    ) -> dict:
        r"""
        Evaluates custom code on feature values of all cases in the trainee.

        Parameters
        ----------
        features_to_code_map : dict[str, str]
            A dictionary with feature name keys and custom Amalgam code string
            values.

            The custom code can use \"#feature_name 0\" to reference the value
            of that feature for each case.
        aggregation_code : str, optional
            A string of custom Amalgam code that can access the list of values
            derived form the custom code in features_to_code_map.

            The custom code can use \"#feature_name 0\" to reference the list of
            values derived from using the custom code in features_to_code_map.

        Returns
        -------
        dict
            A dictionary with keys: 'evaluated' and 'aggregated'.

            'evaluated' is a dictionary with feature name
            keys and lists of values derived from the features_to_code_map
            custom code.

            'aggregated' is None if no aggregation_code is given, it otherwise
            holds the output of the custom 'aggregation_code'
        """
        return self.client.evaluate(
            self.id,
            features_to_code_map=features_to_code_map,
            aggregation_code=aggregation_code,
        )

    def _create(
        self, *,
        library_type: Optional[str] = None,
        max_wait_time: Optional[Union[int, float]] = None,
        overwrite: Optional[bool] = False,
        resources: Optional[Union["TraineeResources", Dict[str, Any]]] = None,
    ) -> None:
        """
        Create the trainee at the API.

        Parameters
        ----------
        library_type : str, optional
            The library type of the Trainee.
        max_wait_time : int or float, optional
            The maximum time to wait for the trainee to be created.
        overwrite : bool, optional
            If True, will overwrite an existing trainee with the same name.
        resources : TraineeResources or dict, optional
            The resources to provision for the trainee.

        Returns
        -------
        None
        """
        if not self.id:
            trainee = BaseTrainee(**self.to_dict())
            new_trainee = self.client.create_trainee(
                trainee=trainee,
                library_type=library_type,
                max_wait_time=max_wait_time,
                overwrite_trainee=overwrite,
                resources=resources,
            )
            self._update_attributes(new_trainee)
        self._created = True

    @classmethod
    def from_openapi(
        cls, trainee: BaseTrainee, *, client: Optional[AbstractHowsoClient] = None
    ) -> "Trainee":
        """
        Create Trainee from base class.

        Parameters
        ----------
        trainee : BaseTrainee
            The base trainee instance.
        client : AbstractHowsoClient, optional
            The Howso client instance to use.

        Returns
        -------
        Trainee
            The trainee instance.
        """
        trainee_dict = trainee.to_dict()
        trainee_dict["client"] = client
        return cls.from_dict(trainee_dict)

    @classmethod
    def from_dict(cls, trainee_dict: dict) -> "Trainee":
        """
        Create Trainee from dict.

        Parameters
        ----------
        trainee_dict : Dict
            The Trainee parameters.

        Returns
        -------
        Trainee
            The trainee instance.
        """
        if not isinstance(trainee_dict, dict):
            raise ValueError("`trainee_dict` parameter is not a dict")
        parameters = {"client": trainee_dict.get("client")}
        for key in cls.attribute_map.keys():
            if key in trainee_dict:
                if key == "project_id":
                    parameters["project"] = trainee_dict[key]
                else:
                    parameters[key] = trainee_dict[key]
        return cls(**parameters)

    def __enter__(self) -> "Trainee":
        """Support context managers."""
        self.acquire_resources()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        """Cleanup trainee during exit of context manager."""
        try:
            if self.persistence != "never" and (self.name or self._was_saved):
                self.release_resources()
            else:
                self.delete()
        except HowsoApiError as error:
            if error.status != 404:
                raise


def delete_trainee(
    name_or_id: Optional[str] = None,
    file_path: Optional[Union[Path, str]] = None,
    client: Optional[AbstractHowsoClient] = None
) -> None:
    """
    Delete an existing Trainee.

    Loaded trainees exist in memory while also potentially existing on disk. This is a convenience function that
    allows the deletion of Trainees from both memory and disk.

    Parameters
    ----------
    name_or_id : str, optional
        The name or id of the trainee. Deletes the Trainees from memory and attempts to delete a Trainee saved under
        the same filename from the default save location if no `file_path` is provided.
    file_path : Path or str, optional
        The path of the file to load the Trainee from. Used for deleting trainees from disk.

        The file path must end with a filename, but file path can be either an absolute path, a
        relative path or just the file name.

        If `name_or_id` is not provided, in addition to deleting from disk, will attempt to
        delete a Trainee from memory assuming the Trainee has the same name as the filename.

        If `file_path` is a relative path the absolute path will be computed
        appending the `file_path` to the CWD.

        If `file_path` is an absolute path, this is the absolute path that
        will be used.

        If `file_path` is just a filename, then the absolute path will be computed
        appending the filename to the CWD.
    client : AbstractHowsoClient, optional
        The Howso client instance to use.

    Returns
    -------
    None
    """
    client = client or get_client()
    if name_or_id is None and file_path is None:
        raise ValueError(
            'Either the `name_or_id` or the `file_path` must be provided.'
        )

    # Check if file exists
    if file_path:
        if not isinstance(client, HowsoDirectClient):
            raise HowsoError(
                "Deleting trainees from using a file path is only"
                "supported with a HowsoDirectClient.")

        file_path = Path(file_path)
        file_path = file_path.expanduser().resolve()
        if not file_path.exists():
            raise ValueError(f"File '{file_path}' does not exist.")

        client.delete_trainee(trainee_id=str(name_or_id), file_path=file_path)
    else:
        client.delete_trainee(trainee_id=str(name_or_id))


def load_trainee(
    file_path: Union[Path, str],
    client: Optional[HowsoDirectClient] = None
) -> "Trainee":
    """
    Load an existing trainee from disk.

    Parameters
    ----------
    file_path : Path or str
        The path of the file to load the Trainee from. This path can contain
        an absolute path, a relative path or simply a file name. A `.caml` file name
        must be always be provided if file paths are provided.

        If `file_path` is a relative path the absolute path will be computed
        appending the `file_path` to the CWD.

        If `file_path` is an absolute path, this is the absolute path that
        will be used.

        If `file_path` is just a filename, then the absolute path will be computed
        appending the filename to the CWD.

    client : HowsoDirectClient, optional
        The Howso client instance to use.

    Returns
    -------
    Trainee
        The trainee instance.
    """
    client = client or get_client()

    if not isinstance(client, HowsoDirectClient):
        raise HowsoError("To save, `client` must be HowsoDirectClient.")

    if not isinstance(file_path, Path):
        file_path = Path(file_path)

    trainee_id = str(uuid.uuid4())

    file_path = file_path.expanduser().resolve()

    # It is decided that if the file contains a suffix then it contains a
    # file name.
    if file_path.suffix:
        # Check to make sure sure `.caml` file is provided
        if file_path.suffix.lower() != '.caml':
            raise HowsoError(
                'Filepath with a non `.caml` extension was provided.'
            )
    else:
        # Add the extension to the file_path
        raise HowsoError(
            'A `.caml` file must be provided.'
        )
    # If path is not absolute, append it to the default directory.
    if not file_path.is_absolute():
        file_path = client.howso.default_save_path.joinpath(file_path)

    # Ensure the parent path exists.
    if not file_path.parents[0].exists(): # Noqa
        raise HowsoError(
            f'The specified directory "{file_path.parents[0]}" does not exist.')

    ret = client.howso.load(trainee_id, file_path.stem, f"{file_path.parents[0]}/")

    if ret is None:
        raise HowsoError(f"Trainee from file '{file_path}' not found.")

    trainee = client._get_trainee_from_core(trainee_id)
    client.trainee_cache.set(trainee, entity_id=client.howso.handle)

    trainee = Trainee.from_openapi(trainee, client=client)
    trainee._custom_save_path = file_path

    return trainee


def get_trainee(
    name_or_id: str,
    *,
    client: Optional[AbstractHowsoClient] = None
) -> "Trainee":
    """
    Get an existing trainee from Howso Services.

    Parameters
    ----------
    name_or_id : str
        The name or id of the trainee.
    client : AbstractHowsoClient, optional
        The Howso client instance to use.

    Returns
    -------
    Trainee
        The trainee instance.
    """
    client = client or get_client()
    trainee = client.get_trainee(str(name_or_id))
    return Trainee.from_openapi(trainee, client=client)


def list_trainees(
    search_terms: Optional[str] = None,
    *,
    client: Optional[AbstractHowsoClient] = None,
    project: Optional[Union[str, BaseProject]] = None,
) -> List["TraineeIdentity"]:
    """
    Get listing of available trainees.

    This method only returns a simplified informational listing of available
    trainees, not full engine Trainee instances. To get a Trainee instance
    that can be used with the engine API call `get_trainee`.

    Parameters
    ----------
    search_terms : str
        Terms to filter results by.
    client : AbstractHowsoClient, optional
        The Howso client instance to use.
    project : str or Project, optional
        The instance or id of a project to filter by.

    Returns
    -------
    list of TraineeIdentity
        The list of available trainees.
    """
    client = client or get_client()

    params = {'search_terms': search_terms}

    # Only pass project_id for platform clients
    if project is not None and isinstance(client, ProjectClient):
        if isinstance(project, BaseProject):
            params["project_id"] = project.id
        else:
            params["project_id"] = project

    return client.get_trainees(**params)

howso/engine/trainee.py
howsoai/howso-engine-py
"""The Python API for the Howso Scikit Client."""

from .scikit import (  # noqa: F401
    ACTION,
    CLASSIFICATION,
    DEFAULT_TTL,
    FEATURE,
    HowsoClassifier,
    HowsoEstimator,
    HowsoRegressor,
    REGRESSION,
)

__all__ = [
    "HowsoEstimator",
    "HowsoRegressor",
    "HowsoClassifier",
    "CLASSIFICATION",
    "REGRESSION",
    "FEATURE",
    "ACTION",
    "DEFAULT_TTL",
]

howso/scikit/__init__.py
howsoai/howso-engine-py
import inspect
from typing import Dict, List, Optional, Union
import uuid

from howso import engine
from howso.client import AbstractHowsoClient, HowsoPandasClient
from howso.client.exceptions import (
    HowsoApiError,
    HowsoError,
    HowsoNotUniqueError,
)
import howso.utilities as utils
from howso.utilities.feature_attributes import infer_feature_attributes
import numpy as np
import pandas as pd
from sklearn.base import BaseEstimator
from sklearn.metrics import accuracy_score, r2_score

CLASSIFICATION = 'classification'
REGRESSION = 'regression'
FEATURE = 'x'
ACTION = 'y'
DEFAULT_TTL = 43200000
RENAME_RETRIES = 3


class HowsoEstimator(BaseEstimator):
    """
    This class is intended for use within scikit-learn only.

    This Estimator follows scikit-learn's conventions. For access to a wider
    range of Howso capabilities, please use the client specified in the
    howso.client module.

    Parameters
    ----------
    features : dict of str: dict, default None
        The features that will predict the targets(s). Will be generated
        automatically if not specified.

        Example::

            {
                "feature_name": {
                    "parameter1" : "value1",
                    "parameter2" : "value2"
                },
                "length": { "type" : "continuous", "decimal_places": 1 },
                "width": { "type" : "continuous", "significant_digits": 4 },
                "degrees": { "type" : "continuous", "cycle_length": 360 },
                "class": { "type" : "nominal" }
            }

    targets : dict of str: dict, default None
        The target(s) to be predicted. Will be generated automatically if not
        specified.

        Example::

            {
                "`target_name`": {
                    "parameter1" : "value1",
                    "parameter2" : "value2"
                },
                "klass": { "type" : "nominal" }
            }

    client : AbstractHowsoClient, default None
        A subclass of AbstractHowsoClient used to interface with Howso.
    method : str
        One of 'classification' or 'regression'.
    verbose : boolean, default False
        A flag for verbose output.
    debug : boolean, default False
        A flag for debug output.
    ttl : int, in milliseconds
        The maximum time a server should maintain a connection open for a
        trainee when processing requests.
    client_params : dict, default None
        The parameters with which to instantiate the client.
    trainee_params : dict, default None
        The parameters with which to instantiate the trainee.

    Examples
    --------
    >>> import pandas as pd
    >>> from howso.scikit import HowsoClassifier
    >>> from sklearn.model_selection import train_test_split
    >>> # Read in the data.
    >>> df = pd.read_csv('iris.csv')
    >>>
    >>> # Split the dataset into the feature (X) and targets (y) and convert
    >>> # the string targets into integer hashes.
    >>> X = df.drop('class', axis=1).values.astype(float)
    >>> y = df['class'].apply(hash).values.astype(int)
    >>>
    >>> # Split the dataset into an 80/20 train/test set.
    >>> X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, shuffle=True, random_state=1)
    >>>
    >>> # Create a classifier.
    >>> howso = HowsoClassifier()
    >>>
    >>> # Fit the training data.
    >>> howso.fit(X_train, y_train)
    >>>
    >>> # Test against the reserved test data.
    >>> score = howso.score(X_test, y_test)
    >>>
    >>> # Print the resulting accuracy.
    >>> print(score)
    0.9666666666666667
    """

    def __init__(self, client: Optional[AbstractHowsoClient] = None,
                 features: Optional[Dict] = None, targets: Dict = None,
                 method: Optional[str] = None, verbose: bool = False,
                 debug: bool = False, ttl: int = DEFAULT_TTL,
                 trainee_params: Optional[Dict] = None,
                 client_params: Optional[Dict] = None):
        """Initialize HowsoEstimator."""
        if method not in [CLASSIFICATION, REGRESSION]:
            raise ValueError(f'Unsupported method {method}')

        if features is None and targets or features and targets is None:
            raise ValueError(
                "This package only supports supervised learning. Please "
                "specify both features and targets. This feature will be "
                "supported in more flexible frameworks in the future. For "
                "access to a wider range of Howso capabilities, please "
                "use the client specified in the howso.client module.")

        self.features = features
        self.targets = targets
        self.conviction_ = None
        self.method = method
        self.feature_names = []
        self.verbose = verbose
        self.debug = debug
        self.ttl = ttl
        self.client = client
        self.trainee_params = trainee_params
        self.client_params = client_params

        if client is None and client_params is None:
            self.client = HowsoPandasClient(verbose=self.verbose, debug=self.debug)
            self.client_params = self._get_client_params()
        elif client_params:
            cls = client_params["class"]
            self.client = cls(**client_params["args"])
            self.client_params = client_params
        else:
            self.client = client
            self.client_params = self._get_client_params()

        if trainee_params:
            self.trainee = trainee_params["args"]

    def __del__(self) -> None:
        """
        Clean up at garbage collection time.

        Returns
        -------
        None
        """
        self.release_resources()

    def release_resources(self):  # noqa: C901
        """
        Release trainee resources created by this estimator.

        If this estimator's trainee is named (self._trainee_name is not None)
        then we'll make an effort to persist the trainee to disk and release
        it's resources. If the data persistence policy forbids this, that call
        will return an error. Upon error, `delete_trainee()` instead.

        NOTE: Errors are handled immediately because this is the instance's
              destructor. There is no further recourse at this point.

        Returns
        -------
        None
        """
        if getattr(self, 'trainee_id', None):
            # If the user named the trainee, they'll want to use it again later
            # so persist the trainee, rather than delete it, if possible.
            try:
                trainee_name = self.trainee.name
            except AttributeError:
                trainee_name = None
            if trainee_name:
                try:
                    # Ensure we have an up-to-date persistence
                    live_trainee = engine.get_trainee(self.trainee_id)
                    if getattr(live_trainee, 'persistence', '') == 'never':
                        raise AssertionError('Trainee is not persistable.')
                    else:
                        self.trainee.release_resources()
                except Exception as e:
                    if isinstance(e, AssertionError):
                        print("The Howso estimator's trainee was not "
                              "permitted to be saved. It will be deleted.")
                    else:
                        print("An error prevented the saving of the Howso "
                              "estimator's trainee. It will be deleted.")
                    try:
                        self.trainee.delete()
                    except Exception:  # noqa: Deliberately broad
                        print("The Howso estimator could not delete "
                              "its trainee.")
                    else:
                        if self.verbose:
                            print("The Howso estimator has successfully "
                                  "deleted its trainee.")
                else:
                    if self.verbose:
                        print(f'The Howso estimator\'s trainee with name '
                              f'"{trainee_name}" and ID "{self.trainee_id}" '
                              f'was successfully released.')
            else:
                # User has no interest in saving this trainee, Just delete the
                # trainee. Be silent unless error or the verbose flag is set.
                try:
                    self.trainee.delete()
                except Exception:  # noqa: Deliberately broad
                    print("The Howso estimator did not successfully "
                          "delete its trainee.")
                else:
                    if getattr(self, 'verbose', False):
                        print("The Howso estimator has successfully "
                              "deleted its trainee.")

        elif getattr(self, 'verbose', False):
            print("The Howso estimator has no trainees to delete.")

    def _get_trainee_params(self) -> Dict:
        """
        Gets the initial parameters of `self.trainee`.

        This allows the trainee to be recreated if the estimator is cloned.
        This code was borrowed from
        `sklearn.base.BaseEstimator._get_param_names`.

        Returns
        -------
        trainee_params: mapping of string to any
            Parameter names mapped to their values.
        """
        trainee_params = {}
        trainee_class = self.trainee.__class__
        init_signature = inspect.signature(trainee_class.__init__)
        parameters = [p.name for p in init_signature.parameters.values()
                      if p.name != "self" and p.kind != p.VAR_KEYWORD]
        trainee_params["args"] = {p: getattr(self.trainee_id, p, None) for p in parameters}
        return trainee_params

    def _get_client_params(self) -> Dict:
        """
        Get the initial parameters of `self.client`.

        This allows the client to be recreated if the estimator is cloned. This
        code was borrowed from `sklearn.base.BaseEstimator._get_param_names`.

        Returns
        -------
        client_params: mapping of string to any
            Parameter names mapped to their values.
        """
        client_params = dict()
        client_class = self.client.__class__
        init_signature = inspect.signature(client_class.__init__)
        parameters = [p.name for p in init_signature.parameters.values()
                      if p.name != "self" and p.kind != p.VAR_KEYWORD]
        client_params["args"] = {p: getattr(self.client, p, None) for p in parameters}
        client_params["class"] = client_class
        return client_params

    @property
    def trainee_id(self) -> Union[str, None]:
        """Return the trainee's ID, if possible."""
        try:
            return self.trainee.id
        except AttributeError:
            return None

    @property
    def trainee_name(self) -> Union[str, None]:
        """Return the trainee name (getter)."""
        return self.trainee.name

    @trainee_name.setter
    def trainee_name(self, name: str = ''):
        """
        Setter for the `trainee_name` property.

        The name must be unique. If it is not, a ValueError is raised.

        Raises
        ------
        HowsoNotUniqueError
            When there is an attempt to set a name that is not unique.
        HowsoError
            On any other issue.
        """
        old_name = self.trainee.name
        self.trainee.name = name
        try:
            self.trainee.update()
        except Exception as exc:  # noqa: Deliberately broad
            self.trainee.name = old_name
            if (
                    isinstance(exc, HowsoApiError) and
                    getattr(exc, 'status', 0) == 409
            ):
                raise HowsoNotUniqueError(
                    f'Unable to set the name of the Howso estimator\'s '
                    f'trainee to: "{name}". Please use a unique name and '
                    f'try again.') from exc
            else:
                raise HowsoError(
                    f'Unable to set the name of the Howso estimator\'s '
                    f'trainee to: "{name}".') from exc
        else:
            if self.verbose:
                print(f'The trainee name was successfully set '
                      f'to "{self.trainee.name}".')

    def get_params(self, deep=True) -> Dict:
        """
        Get parameters for this estimator.

        This code is taken from the source of `sklearn.base.BaseEstimator` and
        lightly modified to avoid calling the `get_params` method
        of `self.trainee`.

        Parameters
        ----------
        deep : bool, default=True
            If True, will return the parameters for this estimator and
            contained subobjects that are estimators.

        Returns
        -------
        params : mapping of string to any
            Parameter names mapped to their values.
        """
        out = dict()
        for key in self._get_param_names():
            try:
                value = getattr(self, key)
            except AttributeError:
                value = None

            if key == "client":
                # Never recurse into `client`.
                continue
            elif deep and hasattr(value, 'get_params'):
                deep_items = value.get_params().items()
                out.update((key + '__' + k, val) for k, val in deep_items)
                out[key] = value
        return out

    def fit(self, X, y, analyze=True) -> "HowsoEstimator":
        """
        Fit a model with Howso.

        Parameters
        -----------
        X : numpy.ndarray, shape (n_samples, n_features)
            Data
        y : numpy.ndarray, shape (n_samples,)
            Target. Will be cast to X's dtype if necessary
        analyze : bool, default=True
            A flag to not analyze the trainee by default

                - A user may plan to call analyze themselves after fit() to specify parameters

        Returns
        -------
        HowsoEstimator
            self
        """
        if not isinstance(X, np.ndarray):
            X = np.asarray(X)
        if not isinstance(y, np.ndarray):
            y = np.asarray(y)

        X, y = utils.align_data(X, y)

        if self.features is None:
            if self.verbose:
                print('Generating features x0, x1 ... xn and target y.')
            self._generate_features_and_targets(X)

        # set/update self.feature_names and self.target_names
        self._store_feature_and_target_names()

        # In Howso 'features' can be either predictors or targets.
        if not self.trainee_params:

            self.trainee = engine.Trainee(
                features={**self.features, **self.targets},
                default_action_features=self.target_names,
                default_context_features=self.feature_names,
                metadata={'scikit-trainee': True},
                client=self.client
            )

            self.trainee_params = self._get_trainee_params()

        self.persistence = self.trainee.persistence

        self._train(X, y)

        if analyze:
            if self.verbose:
                print('Analyzing trainee')
            self.analyze()

        return self

    def partial_fit(self, X, y):
        """
        Add data to an existing Howso model.

        Parameters
        ----------
        X : numpy.ndarray, shape (n_samples, n_features)
            Data
        y : numpy.ndarray, shape (n_samples,)
            Target. Will be cast to X's dtype if necessary
        """
        if not isinstance(X, np.ndarray):
            X = np.asarray(X)
        if not isinstance(y, np.ndarray):
            y = np.asarray(y)

        X, y = utils.align_data(X, y)
        self._train(X, y)

    def predict(self, X) -> np.ndarray:
        """
        Make predictions using Howso.

        Parameters
        ----------
        X : numpy.ndarray, shape (n_samples, n_features)
            Data

        Returns
        -------
        numpy.ndarray, shape (n_samples,)
            The predicted values based on the feature values provided.
        """
        X = utils.align_data(X)
        cases = X.tolist()
        cases = utils.replace_nan_with_none(cases)

        results = self.trainee.react(
            contexts=cases,
            action_features=self.target_names,
            context_features=self.feature_names
        )

        # Convert to dictionary, new trainee outputs a pd.DataFrame
        results['action'] = results['action'].to_dict('records')
        results['action'] = utils.replace_none_with_nan(results['action'])
        action_values = pd.DataFrame(results['action']).values
        out = np.array(action_values).astype(float)
        out.shape = (out.shape[0],)
        if np.isnan(np.sum(out)):
            print('Server returned NaN with predictions.')
        return out

    def score(self, X, y) -> float:
        """
        Score Howso.

        For classifiers, accuracy is calculated.
        For regressors, R^2 is calculated.

        Parameters
        ----------
        X : numpy.ndarray, shape (n_samples, n_features)
            Test samples.

        y : numpy.ndarray, shape (n_samples) or (n_samples, n_outputs)
            True values for X.

        Returns
        -------
        float
            The mean squared error or accuracy

        """
        X, y = utils.align_data(X, y)
        if self.method == CLASSIFICATION:
            return accuracy_score(y, self.predict(X))
        return r2_score(y, self.predict(X))

    def react_into_features(
        self,
        features=None,
        *,
        distance_contribution=False,
        familiarity_conviction_addition=False,
        familiarity_conviction_removal=False,
        p_value_of_addition=False,
        p_value_of_removal=False,
        similarity_conviction=False,
        use_case_weights=False,
        weight_feature=None,
    ) -> None:
        """
        Calculate conviction and other data and stores them into features.

        Parameters
        ----------
        features : list of str
            A list of the feature names to use when calculating conviction.
        distance_contribution : bool or str, default False
            The name of the feature to store distance contribution. If set to
            True the values will be stored to the feature
            'distance_contribution'.
        familiarity_conviction_addition : bool or str, default False
            The name of the feature to store conviction of addition values. If
            set to True the values will be stored to the feature
            'familiarity_conviction_addition'.
        familiarity_conviction_removal : bool or str, default False
            The name of the feature to store conviction of removal values. If
            set to True the values will be stored to the feature
            'familiarity_conviction_removal'.
        p_value_of_addition : bool or str, default False
            The name of the feature to store p value of addition values. If set
            to True the values will be stored to the feature
            'p_value_of_addition'.
        p_value_of_removal : bool or str, default False
            The name of the feature to store p value of removal values. If set
            to True the values will be stored to the feature
            'p_value_of_removal'.
        similarity_conviction : bool or str, default False
            The name of the feature to store similarity conviction
            values. If set to True the values will be stored to the feature
            'similarity_conviction'.
        use_case_weights : bool, default False
            When True, will scale influence weights by each case's
            `weight_feature` weight.
        weight_feature : str, optional
            Name of feature whose values to use as case weights.
            When left unspecified uses the internally managed case weight.
        """
        # If features is not provided, use all features by default.
        if features is None:
            features = self.feature_names

        # Call conviction store on the trainee.
        self.trainee.react_into_features(
            features=features,
            distance_contribution=distance_contribution,
            familiarity_conviction_addition=familiarity_conviction_addition,
            familiarity_conviction_removal=familiarity_conviction_removal,
            p_value_of_addition=p_value_of_addition,
            p_value_of_removal=p_value_of_removal,
            similarity_conviction=similarity_conviction,
            use_case_weights=use_case_weights,
            weight_feature=weight_feature,
        )

    def describe_prediction(self, X, details=None) -> Dict:
        """
        Describe a prediction in detail.

        Parameters
        ----------
        X : numpy.ndarray
            Feature values.

        details: dict, default None
            (Optional) If details are specified, the response will
            contain the requested explanation data along with the reaction.
            Below are the valid keys and data types for the different audit
            details. Omitted keys, values set to None, or False values for
            Booleans will not be included in the audit data returned.

            - boundary_cases : bool, optional
                If True, outputs an automatically determined (when
                'num_boundary_cases' is not specified) relevant number of
                boundary cases. Uses both context and action features of the
                reacted case to determine the counterfactual boundary based on
                action features, which maximize the dissimilarity of action
                features while maximizing the similarity of context features.
                If action features aren't specified, uses familiarity conviction
                to determine the boundary instead.
            - boundary_cases_familiarity_convictions : bool, optional
                If True, outputs familiarity conviction of addition for each of
                the boundary cases.
            - case_contributions : bool, optional
                If True, outputs each influential case's differences between the
                predicted action feature value and the predicted action feature
                value if each individual case were not included. Uses only the
                context features of the reacted case to determine that area.
                Relies on 'robust_computation' parameter to determine whether
                to do standard or robust computation.
            - case_feature_residuals : bool, optional
                If True, outputs feature residuals for all (context and action)
                features for just the specified case. Uses leave-one-out for
                each feature, while using the others to predict the left out
                feature with their corresponding values from this case. Relies
                on 'robust_computation' parameter to determine whether to do
                standard or robust computation.
            - case_mda : bool, optional
                If True, outputs each influential case's mean decrease in
                accuracy of predicting the action feature in the local model
                area, as if each individual case were included versus not
                included. Uses only the context features of the reacted case to
                determine that area. Relies on 'robust_computation' parameter
                to determine whether to do standard or robust computation.
            - categorical_action_probabilities : bool, optional
                If True, outputs probabilities for each class for the action.
                Applicable only to categorical action features.
            - distance_contribution : bool, optional
                If True, outputs the distance contribution (expected total
                surprisal contribution) for the reacted case. Uses both context
                and action feature values.
            - distance_ratio : bool, optional
                If True, outputs the ratio of distance (relative surprisal)
                between this reacted case and its nearest case to the minimum
                distance (relative surprisal) in between the closest two cases
                in the local area. All distances are computed using only the
                specified context features.
            - feature_contributions : bool, optional
                If True, outputs each context feature's differences between the
                predicted action feature value and the predicted action feature
                value if each context were not in the model for all context
                features in the local model area. Relies 'robust_computation'
                parameter to determine whether to do standard or robust
                computation.
            - feature_mda : bool, optional
                If True, outputs each context feature's mean decrease in
                accuracy of predicting the action feature given the context.
                Uses only the context features of the reacted case to determine
                that area. Relies on 'robust_computation' parameter to
                determine whether to do standard or robust computation.
            - feature_mda_ex_post : bool, optional
                If True, outputs each context feature's mean decrease in
                accuracy of predicting the action feature as an explanation
                given that the specified prediction was already made as
                specified by the action value. Uses both context and action
                features of the reacted case to determine that area. Relies on
                'robust_computation' parameter to determine whether to do
                standard or robust computation.
            - feature_residuals : bool, optional
                If True, outputs feature residuals for all (context and action)
                features locally around the prediction. Uses only the context
                features of the reacted case to determine that area. Relies on
                'robust_computation' parameter to determine whether to do
                standard or robust computation.
            - global_case_feature_residual_convictions : bool, optional
                If True, outputs this case's feature residual convictions for
                the global model. Computed as: global model feature residual
                divided by case feature residual. Relies on
                'robust_computation' parameter to determine whether to do
                standard or robust computation.
            - hypothetical_values : dict, optional
                A dictionary of feature name to feature value. If specified,
                shows how a prediction could change in a what-if scenario where
                the influential cases' context feature values are replaced with
                the specified values.  Iterates over all influential cases,
                predicting the action features each one using the updated
                hypothetical values. Outputs the predicted arithmetic over the
                influential cases for each action feature.
            - influential_cases : bool, optional
                If True, outputs the most influential cases and their influence
                weights based on the surprisal of each case relative to the
                context being predicted among the cases. Uses only the context
                features of the reacted case.
            - influential_cases_familiarity_convictions :  bool, optional
                If True, outputs familiarity conviction of addition for each of
                the influential cases.
            - influential_cases_raw_weights : bool, optional
                If True, outputs the surprisal for each of the influential
                cases.
            - local_case_feature_residual_convictions : bool, optional
                If True, outputs this case's feature residual convictions for
                the region around the prediction. Uses only the context
                features of the reacted case to determine that region.
                Computed as: region feature residual divided by case feature
                residual. Relies on 'robust_computation' parameter to determine
                whether to do standard or robust computation.
            - most_similar_cases : bool, optional
                If True, outputs an automatically determined (when
                'num_most_similar_cases' is not specified) relevant number of
                similar cases, which will first include the influential cases.
                Uses only the context features of the reacted case.
            - num_boundary_cases : int, optional
                Outputs this manually specified number of boundary cases.
            - num_most_similar_cases : int, optional
                Outputs this manually specified number of most similar cases,
                which will first include the influential cases.
            - num_most_similar_case_indices: int, optional
                Outputs this specified number of most similar case indices when
                'distance_ratio' is also set to True.
            - observational_errors : bool, optional
                If True, outputs observational errors for all features as
                defined in feature attributes.
            - outlying_feature_values : bool, optional
                If True, outputs the reacted case's context feature values that
                are outside the min or max of the corresponding feature values
                of all the cases in the local model area. Uses only the context
                features of the reacted case to determine that area.
            - similarity_conviction : bool, optional
                If True, outputs similarity conviction for the reacted case.
                Uses both context and action feature values as the case values
                for all computations. This is defined as expected (local)
                distance contribution divided by reacted case distance
                contribution.
            - robust_computation : bool, optional
                Default is False, uses leave-one-out for features (or cases,
                as needed) for all relevant computations. If True, uses
                uniform sampling from the power set of all combinations of
                features (or cases, as needed) instead.

            >>> details = {'num_most_similar_cases': 5,
            ...            'feature_residuals': True}


        Returns
        -------
        dict
            Format of::

                {
                    'action': list of dicts of action_features -> action_values,
                    'explanation': dict with requested audit data
                }

        """
        if details is None:
            details = {
                'num_boundary_cases': 3,
                'case_feature_residuals': True,
                'feature_mda': True,
                'feature_residuals': True,
                'influential_cases': True,
                'num_most_similar_cases': 3,
            }

        if not isinstance(X, np.ndarray):
            X = np.asarray(X)
        if X.ndim == 1:
            X = np.reshape(X, (1, X.shape[0]))

        if len(X.shape) > 1:
            assert X.shape[1] == len(self.feature_names), 'Number of feature values do not match number of ' \
                                                          'feature names.'
        elif len(X.shape) == 1:
            assert X.shape[0] == len(self.feature_names), 'Number of feature values do not match number of ' \
                                                          'feature names.'
        else:
            assert False, 'Invalid data dimensions.'

        context = X.tolist()
        context = utils.replace_nan_with_none(list(context))
        if details is None:
            audit_data = self.trainee.react(
                contexts=context,
                action_features=self.target_names,
                context_features=self.feature_names
            )
        else:
            audit_data = self.trainee.react(
                contexts=context,
                action_features=self.target_names,
                context_features=self.feature_names,
                details=details
            )

        # Convert to Dictionary
        audit_data['action'] = audit_data['action'].to_dict('records')
        audit_data['action'] = utils.replace_none_with_nan(audit_data['action'])
        return audit_data

    def get_feature_conviction(self, features=None) -> Dict:
        """
        Gets the conviction of the features in a model.

        Parameters
        ----------
        features : str or list of str
            Features to return conviction values for.

        Returns
        -------
        dict
            A map of feature convictions and contributions.
        """
        ret = self.trainee.get_feature_conviction(
            features=self.feature_names,
            action_features=self.target_names,
        )

        feature_conviction = ret.to_dict()

        if features is not None:
            if isinstance(features, str):
                features = [features]

            filtered = {}
            for k, v in feature_conviction.items():
                v = {fkey: fval for fkey, fval in v.items() if fkey in features}
                filtered[k] = v
            feature_conviction = filtered

        return feature_conviction["familiarity_conviction_addition"]

    def get_case_conviction(self, X, features=None) -> List:
        """
        Return case conviction.

        Parameters
        ----------
        X : numpy.ndarray, shape (n_samples, n_features)
            Data
        features : str or list of str
            A list of feature names to calculate convictions.

        Returns
        -------
        list
            The conviction of the cases. Ex: [1.0, 3.2, 0.4]
        """
        # restructure X as needed
        if hasattr(X, "tolist"):
            X = X.tolist()

        if type(X) is not list:
            raise HowsoError("Cases must be a list.")
        elif type(X[0]) is not list:
            X = [X]

        new_case_groups = []
        for group in X:
            if type(group[0]) is not list:
                group = [group]
            new_case_groups.append(group)

        if features is None:
            features = self.feature_names
        elif isinstance(features, str):
            features = [features]

        ret = self.trainee.react_group(
            new_case_groups,
            features=features
        )
        return [case['familiarity_conviction_addition'] for case in ret]

    def _set_random_name(self, retries: int = RENAME_RETRIES):
        """
        Helper to randomly set trainee name with retries.

        Raises
        ------
        HowsoNotUniqueError:
            If unable to set trainee name, even after retrying.
        Exception:
            May raise other exceptions.
        """
        last_exception = None
        for _ in range(max(1, retries)):
            try:
                self.trainee_name = f'howso-estimator-{uuid.uuid4()}'
            except HowsoNotUniqueError as exception:
                last_exception = exception
                continue
            else:
                break
        else:
            if last_exception:
                raise last_exception

    def __getstate__(self) -> Dict:
        """
        Returns the state of this object (self.__dict__).

        Additionally saves the model on the Howso server for later loading
        when unpickling.

        If this trainee has not already been named, then this method will set
        a randomly generated one. This is done so that the destructor will
        `release_trainee_resources`, otherwise it will `delete_trainee`.

        Returns
        -------
        dict
            self.__dict__

        Raises
        ------
        HowsoNotUniqueError:
            If unable to set the trainee name w/up to RENAME_RETRIES retries.
        Exception:
            if unable to persist the trainee.
        """
        if not self.trainee.name:
            # This may raise, but let it.
            self._set_random_name()

        self.trainee.persist()
        return self.__dict__

    def __setstate__(self, state: Dict):
        """
        Receives the state of the object when unpickling.

        Explicitly sets the object state. Additionally calls `load` on the
        Howso server to load the model.

        Parameters
        ----------
        state : dict
            The state of the HowsoEstimator.
        """
        for attr in state:
            setattr(self, attr, state[attr])
        self.load(self.trainee_id)

    def delete(self):
        """Delete this trainee from the howso cloud service."""
        if self.trainee_id:
            self.trainee.delete()

    def save(self) -> None:
        """
        Persist the trainee.

        By default model resources are released after a short period of time.
        This method saves the model persistently to allow releasing trainee
        resources while keeping the model available for use later.

        If this trainee has not already been named, then this method will set
        a randomly generated one.

        Raises
        ------
        HowsoNotUniqueError:
            If unable to set the trainee name w/up to RENAME_RETRIES retries.
        Exception:
            if unable to persist the trainee.
        """
        if not self.trainee.name:
            # This may raise, but let it.
            self._set_random_name()

        if self.persistence == 'never':
            raise HowsoError("The Howso estimator's trainee was not "
                             "permitted to be saved.")
        else:
            self.trainee.persist()

    def load(self, trainee_id: str):
        """
        Load a model from the server.

        Parameters
        ----------
        trainee_id : str
            Id of the trainee. (can be obtained from this class).

        """
        self._store_feature_and_target_names()
        self.trainee = self.trainee.get_trainee(trainee_id=trainee_id)
        self.trainee.acquire_resources()

    def _train(self, X: np.ndarray, y: np.ndarray):
        """
        Train a Howso model.

        Parameters
        ----------
        X : numpy.ndarray, shape (n_samples, n_features)
            Data
        y : numpy.ndarray, shape (n_samples,)
            Target. Will be cast to X's dtype if necessary
        """
        self.trainee.train(cases=build_cases(X, y), features=self.feature_names + self.target_names)

    def _store_feature_and_target_names(self):
        self._store_feature_names()
        self._store_target_names()

    def _store_feature_names(self):
        self.feature_names = []
        for fname in self.features.keys():
            self.feature_names.append(str(fname))

    def _store_target_names(self):
        self.target_names = []
        for tname in self.targets.keys():
            self.target_names.append(str(tname))

    def analyze(self, seed=None, **kwargs):
        """
        Analyze a trainee.

        Parameters
        ----------
        seed : int, optional
            A random seed.
        **kwargs
            Refer to docstring in howso.client.analyze method for complete
            reference of all parameters
        """
        if not kwargs:
            kwargs = dict()

        if seed is not None:
            self.trainee.set_random_seed(seed)

        if kwargs.get('targeted_model', "") == 'targetless':
            # for targetless analyze, override 'action_features'
            # and 'context_features'
            kwargs['action_features'] = []
            kwargs['context_features'] = self.feature_names + self.target_names
        else:
            if 'action_features' not in kwargs:
                kwargs['action_features'] = self.target_names

            if 'context_features' not in kwargs:
                kwargs['context_features'] = self.feature_names

        self.trainee.analyze(**kwargs)

    def partial_unfit(self, precision: str, num_cases: int,
                      criteria: Optional[Dict] = None):
        """
        Remove a training case from a trainee.

        The training case will be completely purged from the model and the
        model will behave as if it had never been trained with this training
        case.

        Parameters
        ----------
        precision : str
            The precision to use when removing the case. Options are 'exact' or
            'similar'.
        num_cases : int
            The number of cases to remove; minimum 1 case must be removed.
        criteria : dict, default None
            The condition map to select the cases to remove that meet all the
            provided conditions. Keys - features, values - one of | null (must
            have the feature) | a value (must match exactly) | an array of two
            values (a range, feature values must be between)
        """
        self.trainee.remove_cases(precision, num_cases, criteria)

    def feature_add(self, feature: str = None,
                    value: Union[float, int, str, None] = None):
        """
        Add a feature to a trainee.

        Parameters
        ----------
        feature : str, optional
            The name of the feature. Will be generated automatically
            if not specified.
        value : int or float or str, optional
            The value to populate the feature with.
        """
        # Question: update default_context_features?
        if feature is None:
            feature = self._generate_new_feature_name()
        elif feature in self.feature_names:
            raise HowsoError(f"Feature name '{feature}' already exists in "
                             f"this trainee.")

        self.trainee.add_feature(feature, feature_value=value)

    def feature_remove(self, feature: Optional[str] = None):
        """
        Remove a feature from a trainee.

        Parameters
        ----------
        feature : str, default None
            Optional. The name of the feature to remove. Will quietly do
            nothing if the feature was not found.
        """
        # Question: can a target be removed?
        try:
            feature_index = self.feature_names.index(feature)
        except Exception:  # noqa: Intentionally broad.
            feature_index = None

        if feature_index is not None:
            del self.feature_names[feature_index]
        if feature in self.features:
            del self.features[feature]
            self.trainee.remove_feature(feature)

    def _generate_features_and_targets(self, X: np.ndarray):
        """
        Generate feature and target names.

        Parameters
        ----------
        X : numpy.ndarray
            Feature values ndarray.
        """
        # Use infer_feature_attributes to populate features; only accepts pandas df
        df = pd.DataFrame(data=X)
        if isinstance(df.columns, pd.RangeIndex):
            # Convert numeric columns to string
            # Don't use inplace/copy here to support multiple versions of pandas
            df = df.set_axis([str(i) for i in range(X.shape[1])], axis=1)
        self.features = infer_feature_attributes(df)
        targets = {}
        if self.method == CLASSIFICATION:
            targets[ACTION] = {"type": "nominal"}
        else:
            targets[ACTION] = {"type": "continuous"}
        self.targets = targets

    def _generate_new_feature_name(self):
        """Generate a new feature name."""
        name = FEATURE + str(len(self.features))
        self.features[name] = {"type": "continuous"}
        self._store_feature_names()
        return name


def build_cases(X: np.ndarray, y: np.ndarray) -> List:
    """
    Transform the cases from the feature and target ndarrays to a list of case values.

    Parameters
    ----------
    X : numpy.ndarray
        Feature values ndarray.
    y : numpy.ndarray
        Target values ndarray.

    Returns
    -------
    List
        A multi-dimensional List.
    """
    if len(y.shape) == 1:
        y = np.expand_dims(y, axis=1)

    dat = np.concatenate([X, y], axis=1).tolist()
    dat = utils.replace_nan_with_none(dat)

    return dat


class HowsoClassifier(HowsoEstimator):
    """
    A HowsoEstimator for classification analysis.

    Parameters
    ----------
    features : dict of str: dict, default None
        The features that will predict the targets(s). Will be generated
        automatically if not specified.

        Example::

            {
                "feature_name": {
                    "parameter1" : "value1",
                    "parameter2" : "value2"
                },
                "length": { "type" : "continuous", "decimal_places": 1 },
                "width": { "type" : "continuous", "significant_digits": 4 },
                "degrees": { "type" : "continuous", "cycle_length": 360 },
                "class": { "type" : "nominal" }
            }

    targets : dict of str: dict, default None
        The target(s) to be predicted. Will be generated automatically if not
        specified.

        Example::

            {
                "target_name": {
                    "parameter1" : "value1",
                    "parameter2" : "value2"
                },
                "klass": { "type" : "nominal" }
            }

    client : AbstractHowsoClient, default None
        A subclass of AbstractHowsoClient used to interface with Howso.
    verbose : boolean, default False
        A flag for verbose output.
    debug : boolean, default False
        A flag for debug output.
    ttl : int, in milliseconds
        The maximum time a server should maintain a connection open for a
        trainee when processing requests.
    client_params : dict, default None
        The parameters with which to instantiate the client.
    trainee_params : dict, default None
        The parameters with which to instantiate the client. Intended for use by `HowsoEstimator.get_params`.
    """

    def __init__(self, client: Optional[AbstractHowsoClient] = None,
                 features: Optional[Dict] = None,
                 targets: Optional[Dict] = None,
                 verbose: bool = False,
                 debug: bool = False, ttl: int = DEFAULT_TTL,
                 client_params: Optional[Dict] = None,
                 trainee_params: Optional[Dict] = None):
        """Initialize HowsoClassifier."""
        super(HowsoClassifier, self).__init__(client=client,
                                              features=features,
                                              targets=targets,
                                              verbose=verbose, debug=debug,
                                              ttl=ttl,
                                              method=CLASSIFICATION,
                                              client_params=client_params,
                                              trainee_params=trainee_params)
        self.classes_ = np.empty((0,), dtype=str)

    def fit(self, X: np.ndarray, y: np.ndarray, analyze: bool = True):
        """
        Fit a model with Howso.

        Parameters
        -----------
        X : numpy.ndarray, shape (n_samples, n_features)
            Data
        y : numpy.ndarray, shape (n_samples,)
            Target. Will be cast to X's dtype if necessary
        analyze : bool, default=True
            (Optional) If trainee should be analyzed.

                - a user may plan to call analyze themselves after fit() to specify parameters

        Returns
        -------
        HowsoEstimator
            self
        """
        HowsoEstimator.fit(self, X, y, analyze)
        # To keep fit as an idempotent operation, clear out the classes variable before populating them.
        self.classes_ = np.empty((0,), dtype=str)
        self._populate_classes(y)

    def partial_fit(self, X: np.ndarray, y: np.ndarray):
        """
        Adds data to an existing Howso model.

        Parameters
        ----------
        X : numpy.ndarray, shape (n_samples, n_features)
            Data
        y : numpy.ndarray, shape (n_samples,)
            Target. Will be cast to X's dtype if necessary
        """
        HowsoEstimator.partial_fit(self, X, y)
        self._populate_classes(y)

    def load(self, trainee_id: str):
        """
        Load the trainee and re-populates the `classes_` variable.

        This is based on the available classes in the loaded trainee.

        Parameters
        ----------
        trainee_id : str
            The id of the trainee.
        """
        HowsoEstimator.load(self, trainee_id)
        self.classes_ = np.empty((0,), dtype=str)
        cases = self.trainee.get_cases()
        for case in cases.cases:
            if str(case[-1]) in self.classes_:
                self.classes_ = np.append(self.classes_, [case[-1]])
        self.classes_ = np.sort(self.classes_, axis=None)

    def _populate_classes(self, y: np.ndarray):
        """
        Populates the self.classes_ variable.

        This is used in classifiers to get the list of available classes.

        Parameters
        ----------
        y : numpy.ndarray, shape (n_samples,)
            A numpy array with the target values.
        """
        for v in list(y):
            if str(v) not in self.classes_:
                self.classes_ = np.append(self.classes_, v)
        self.classes_ = np.sort(self.classes_, axis=None)

    def predict_proba(self, X: np.ndarray) -> np.ndarray:
        """
        Probability estimates.

        The returned estimates for all classes are ordered by the label
        of classes.

        For a multi_class problem, if multi_class is set to be “multinomial”
        the softmax function is used to find the predicted probability of each
        class. Else use a one-vs-rest approach, i.e calculate the probability
        of each class assuming it to be positive using the logistic function
        and normalize these values across all the classes.

        NOTE: Only works with single target models at this time.

        Parameters
        ----------
        X : numpy.ndarray, shape (n_samples, n_features)
            Data

        Returns
        -------
        numpy.ndarray, shape (n_samples, n_classes)
            The probabilities of the classes for the given prediction.
        """
        if len(self.targets) != 1:
            raise AttributeError("predict_proba is only implemented for single-target models")
        target = self.target_names[0]
        result = self.describe_prediction(X, details={"categorical_action_probabilities": True})
        proba = []
        for exp in result['explanation']['categorical_action_probabilities']:
            sub_probas = []
            for clss in self.classes_:
                sub_probas += [0 if clss not in exp[target] else
                               exp[target][clss]]
            proba += [sub_probas]
        ret = np.array(proba, dtype=float)
        return ret


class HowsoRegressor(HowsoEstimator):
    """
    A HowsoEstimator for regression analysis.

    Parameters
    ----------
    features : dict of str: dict, default None
        The features that will predict the targets(s). Will be generated
        automatically if not specified.

        Example::

            {
                "feature_name": {
                    "parameter1" : "value1",
                    "parameter2" : "value2"
                },
                "length": { "type" : "continuous", "decimal_places": 1 },
                "width": { "type" : "continuous", "significant_digits": 4 },
                "degrees": { "type" : "continuous", "cycle_length": 360 },
                "class": { "type" : "nominal" }
            }

    targets : dict of str: dict, default None
        The target(s) to be predicted. Will be generated automatically if not
        specified.

        Example::

            {
                "target_name": {
                    "parameter1" : "value1",
                    "parameter2" : "value2"
                },
                "klass": { "type" : "nominal" }
            }

    client : AbstractHowsoClient, default None
        A subclass of AbstractHowsoClient used to interface with Howso.
    verbose : boolean, default False
        A flag for verbose output.
    debug : boolean, default False
        A flag for debug output.
    ttl : int, in milliseconds
        The maximum time a server should maintain a connection open for a
        trainee when processing requests.
    client_params : dict, default None
        The parameters with which to instantiate the client.
    trainee_params : dict, default None
        The parameters with which to instantiate the client. Intended for use
        by `HowsoEstimator.get_params`.
    """

    def __init__(self, client=None, features: Optional[Dict] = None,
                 targets: Optional[Dict] = None,
                 verbose: bool = False,
                 debug: bool = False,
                 ttl: int = DEFAULT_TTL,
                 client_params: Optional[Dict] = None,
                 trainee_params: Optional[Dict] = None):
        """Initialize a HowsoRegressor."""
        super(HowsoRegressor, self).__init__(client=client,
                                             features=features,
                                             targets=targets,
                                             verbose=verbose, debug=debug,
                                             ttl=ttl, method=REGRESSION,
                                             client_params=client_params,
                                             trainee_params=trainee_params)

howso/scikit/scikit.py
howsoai/howso-engine-py
"""This module contains various utilities for the Howso clients."""
from .feature_attributes import infer_feature_attributes  # noqa: F401
from .features import (  # noqa: F401
    deserialize_cases,
    FeatureType,
    format_dataframe,
    serialize_cases,
)
from .monitors import ProgressTimer, Timer
from .utilities import (  # noqa: F401
    align_data,
    build_react_series_df,
    CaseIndices,
    check_feature_names,
    date_format_is_iso,
    date_to_epoch,
    deep_update,
    determine_iso_format,
    dprint,
    epoch_to_date,
    get_kwargs,
    is_valid_uuid,
    ISO_8601_DATE_FORMAT,
    ISO_8601_FORMAT,
    LocaleOverride,
    num_list_dimensions,
    replace_doublemax_with_infinity,
    replace_nan_with_none,
    replace_none_with_nan,
    reshape_data,
    seconds_to_time,
    serialize_datetimes,
    StopExecution,
    time_to_seconds,
    trainee_from_df,
    UserFriendlyExit,
    validate_case_indices,
    validate_datetime_iso8061,
    validate_features,
    validate_list_shape,
)

__all__ = [
    "align_data",
    "build_react_series_df",
    "check_feature_names",
    "date_format_is_iso",
    "date_to_epoch",
    "deserialize_cases",
    "determine_iso_format",
    "dprint",
    "epoch_to_date",
    "FeatureType",
    "format_dataframe",
    "get_kwargs",
    "infer_feature_attributes",
    "is_valid_uuid",
    "ISO_8601_DATE_FORMAT",
    "ISO_8601_FORMAT",
    "LocaleOverride",
    "num_list_dimensions",
    "ProgressTimer",
    "replace_doublemax_with_infinity",
    "replace_nan_with_none",
    "replace_none_with_nan",
    "reshape_data",
    "seconds_to_time",
    "serialize_cases",
    "serialize_datetimes",
    "StopExecution",
    "time_to_seconds",
    "Timer",
    "trainee_from_df",
    "UserFriendlyExit",
    "validate_case_indices",
    "validate_datetime_iso8061",
    "validate_features",
    "validate_list_shape",
]

howso/utilities/__init__.py
howsoai/howso-engine-py
"""This module contains tools to infer feature attributes from a variety of data types."""
from .infer_feature_attributes import infer_feature_attributes

__all__ = ["infer_feature_attributes"]

howso/utilities/feature_attributes/__init__.py
howsoai/howso-engine-py
from abc import ABC, abstractmethod
from collections.abc import Container
from copy import deepcopy
from functools import singledispatchmethod
import json
import logging
import math
import numbers
import platform
from typing import (
    Any, Collection, Dict, Iterable, List, Mapping, Optional, Tuple, Union
)
import warnings

from dateutil.parser import isoparse
from dateutil.parser import parse as dt_parse
from howso.utilities.features import FeatureType
from howso.utilities.internals import serialize_openapi_models
import numpy as np
import pandas as pd
import yaml

logger = logging.getLogger(__name__)

# Maximum/minimum data sizes for integers, floats, datetimes supported by the core
INTEGER_MAX = int(math.pow(2, 53))
FLOAT_MAX = 1.7976931348623157 * math.pow(10, 308)
FLOAT_MIN = 2.2250738585072014 * math.pow(10, -308)
WIN_DT_MAX = '6053-01-24'
LINUX_DT_MAX = '2262-04-11'


class FeatureAttributesBase(dict):
    """Provides accessor methods for and dict-like access to inferred feature attributes."""

    def __init__(self, feature_attributes: Dict, params: Dict = {}, unsupported: List[str] = []):
        """
        Instantiate this FeatureAttributesBase object.

        Parameters
        ----------
        feature_attributes : Dict
            The feature attributes dictionary to be wrapped by this object.
        params : Dict
            (Optional) The parameters used in the call to infer_feature_attributes.
        unsupported : List of str
            (Optional) A list of features that contain data that is unsupported by the engine.

        """
        if not isinstance(feature_attributes, Dict):
            raise TypeError('Provided feature attributes must be a Dict.')
        self.params = params
        self.update(feature_attributes)
        self.unsupported = unsupported

    def __copy__(self) -> "FeatureAttributesBase":
        """Return a (deep)copy of this instance of FeatureAttributesBase."""
        cls = self.__class__
        obj_copy = cls.__new__(cls)
        obj_copy.update(deepcopy(self))
        obj_copy.params = self.params
        return obj_copy

    def get_parameters(self) -> dict:
        """
        Get the keyword arguments used with the initial call to infer_feature_attributes.

        Returns
        -------
        Dict
            A dictionary containing the kwargs used in the call to `infer_feature_attributes`.

        """
        return self.params

    def to_json(self) -> str:
        """
        Get a JSON string representation of this FeatureAttributes object.

        Returns
        -------
        String
            A JSON representation of the inferred feature attributes.
        """
        return json.dumps(self)

    def get_names(self, *, types: Union[str, Container] = None,
                  without: Iterable[str] = None
                  ) -> List[str]:
        """
        Get feature names associated with this FeatureAttributes object.

        Parameters
        ----------
        types : String, Container (of String), default None
            (Optional) A feature type as a string (E.g., 'continuous') or a
            list of feature types to limit the output feature names.
        without : Iterable of String
            (Optional) An Iterable of feature names to exclude from the return object.

        Returns
        -------
        List of String
            A list of feature names.
        """
        if without:
            for feature in without:
                if feature not in self.keys():
                    raise ValueError(f'Feature {feature} does not exist in this FeatureAttributes '
                                     'object')
        names = self.keys()

        if types:
            if isinstance(types, str):
                types = [types, ]
            names = [
                name for name in names
                if self[name].get('type') in types
            ]

        return [
            key for key in names
            if without is None or key not in without
        ]

    def _validate_bounds(self, data: pd.DataFrame, feature: str,
                         attributes: Dict) -> List[str]:  # noqa: C901
        """Validate the feature bounds of the provided DataFrame."""
        # Import here to avoid circular import
        from howso.utilities import date_to_epoch

        errors = []

        # Ensure that there are bounds to validate
        if not isinstance(attributes.get('bounds'), Mapping):
            return errors

        # Gather some data to use for validation
        series = data[feature]
        bounds = attributes['bounds']
        min_bound = bounds.get('min')
        max_bound = bounds.get('max')
        # Get unique values but exclude NoneTypes
        unique_values = series.dropna().unique()

        if bounds.get('allowed'):
            # Check nominal bounds
            allowed_values = attributes['bounds']['allowed']
            out_of_band_values = set(unique_values) - set(allowed_values)
            if pd.isna(list(out_of_band_values)).all():
                # Placeholder for behavior when columns contain nans
                pass
            elif out_of_band_values:
                errors.append(f"'{feature}' contains out-of-band values: {out_of_band_values}")
        elif attributes.get('date_time_format'):
            # Since this is a datetime feature, convert dates to epoch time for bounds comparison
            try:
                if min_bound:
                    min_bound_epoch = date_to_epoch(min_bound, time_format=attributes['date_time_format'])
                if max_bound:
                    max_bound_epoch = date_to_epoch(max_bound, time_format=attributes['date_time_format'])
                for value in unique_values:
                    epoch = date_to_epoch(value, time_format=attributes['date_time_format'])
                    if (max_bound and epoch > max_bound_epoch) or (min_bound and epoch < min_bound_epoch):
                        errors.append(f"'{feature}' has a value outside of bounds (min: {min_bound}, "
                                      f"max: {max_bound}): {value}")
            except ValueError as err:
                errors.append(f'Could not validate datetime bounds due to the following error: {err}')
        elif min_bound or max_bound:
            # Check int/float bounds
            for value in unique_values:
                if (max_bound and float(value) > float(max_bound)) or (min_bound and float(value) < float(min_bound)):
                    errors.append(f"'{feature}' has a value outside of bounds (min: {min_bound}, "
                                  f"max: {max_bound}): {value}")

        return errors

    def _validate_dtype(self, data: pd.DataFrame, feature: str,  # noqa: C901
                        expected_dtype: Union[str, pd.CategoricalDtype], coerced_df: pd.DataFrame,
                        coerce: bool = False, localize_datetimes=True) -> List[str]:
        """Validate the data type of a feature and optionally attempt to coerce."""
        errors = []
        series = coerced_df[feature]
        is_valid = False

        if isinstance(expected_dtype, pd.CategoricalDtype):
            # If the feature is a Categorical dtype, try to coerce
            try:
                series = series.astype(expected_dtype)
                if coerce:
                    coerced_df[feature] = series
                is_valid = True
            except Exception: # noqa: Intentionally broad
                pass
        elif expected_dtype == 'datetime64':
            try:
                format = self[feature]['date_time_format']
                if ".%f" in format:
                    format = "ISO8601"
                series = pd.to_datetime(coerced_df[feature], format=format)
                if coerce:
                    if localize_datetimes and not isinstance(series, pd.DatetimeTZDtype):
                        series = series.dt.tz_localize('UTC', ambiguous='infer',
                                                       nonexistent='NaT')
                        coerced_df[feature] = coerced_df[feature].astype(series.dtype)
                    else:
                        coerced_df[feature] = series
                is_valid = True
            except Exception: # noqa: Intentionally broad
                pass
        else:
            # Else, compare the dtype directly
            if data[feature].dtype.name == expected_dtype:
                is_valid = True
            # If the feature can be converted, consider it valid (slightly differing numeric types, etc.)
            else:
                try:
                    series = series.astype(expected_dtype)
                    if coerce:
                        coerced_df[feature] = series
                    is_valid = True
                except pd.errors.IntCastingNaNError:
                    # If this happens, there is a null value, thus a float dtype is OK
                    if pd.api.types.is_float_dtype(series):
                        is_valid = True
                except Exception: # noqa: Intentionally broad
                    pass

        # Raise warnings if the types do not match
        if not is_valid:
            if coerce:
                errors.append(f"Expected dtype '{expected_dtype}' for feature '{feature}' "
                              "but could not coerce.")
            else:
                errors.append(f"Feature '{feature}' should be '{expected_dtype}' dtype, but found "
                              f"'{data[feature].dtype}'")

        return errors

    @staticmethod
    def _allows_null(attributes: Dict) -> bool:
        """Return whether the given attributes indicates the allowance of null values."""
        return 'bounds' in attributes and attributes['bounds'].get('allow_null', False)

    def _validate_df(self, data: pd.DataFrame, coerce: bool = False,  # noqa: C901
                     raise_errors: bool = False, table_name: str = None, validate_bounds=True,
                     allow_missing_features: bool = False, localize_datetimes=True):
        errors = []
        coerced_df = data.copy(deep=True)
        features = self[table_name] if table_name else self

        for feature, attributes in features.items():
            if feature not in data.columns:
                # Check if column is missing (and not supposed to be)
                if not (
                    feature.startswith('.')
                    or (
                        attributes.get('auto_derive_on_train', False)
                        and 'derived_feature_code' in attributes
                    )
                    or allow_missing_features
                ):
                    errors.append(f'{feature} is missing from the dataframe')
                # OK if it's an internal feature or is being processed by Validator
                continue

            # Check nominal types
            if attributes['type'] == 'nominal':
                if attributes.get('data_type') == 'number':
                    # Check type (float)
                    if attributes.get('decimal_places', 0) > 0 or self._allows_null(attributes):
                        errors.extend(self._validate_dtype(data, feature, 'float64',
                                                           coerced_df, coerce=coerce))
                    # Check type (int)
                    else:
                        errors.extend(self._validate_dtype(data, feature, 'int64',
                                                           coerced_df, coerce=coerce))
                elif attributes.get('data_type') == 'boolean':
                    # Check type (boolean)
                    errors.extend(self._validate_dtype(data, feature, 'bool',
                                                       coerced_df, coerce=coerce))
                elif attributes.get('bounds') and attributes['bounds'].get('allowed'):
                    # Check type (categorical)
                    schema_dtype = pd.CategoricalDtype(attributes['bounds']['allowed'],
                                                       ordered=True)
                    errors.extend(self._validate_dtype(data, feature, schema_dtype,
                                                       coerced_df, coerce=coerce))
                else:
                    # Else, should be an object
                    errors.extend(self._validate_dtype(data, feature, 'object',
                                                       coerced_df, coerce=coerce))

            # Check ordinal types
            elif attributes['type'] == 'ordinal':
                if attributes.get('bounds') and attributes['bounds'].get('allowed'):
                    # Check type (categorical)
                    schema_dtype = pd.CategoricalDtype(attributes['bounds']['allowed'],
                                                       ordered=True)
                    errors.extend(self._validate_dtype(data, feature, schema_dtype,
                                                       coerced_df, coerce=coerce))
                # Check type (float)
                elif attributes.get('decimal_places', 0) > 0 or self._allows_null(attributes):
                    errors.extend(self._validate_dtype(data, feature, 'float64',
                                                       coerced_df, coerce=coerce))
                # Check type (int)
                else:
                    errors.extend(self._validate_dtype(data, feature, 'int64',
                                                       coerced_df, coerce=coerce))

            # Check continuous types
            elif attributes['type'] == 'continuous':
                if 'date_time_format' in attributes:
                    # Check type (datetime)
                    errors.extend(self._validate_dtype(data, feature, 'datetime64',
                                                       coerced_df, coerce=coerce,
                                                       localize_datetimes=localize_datetimes))
                elif attributes.get('decimal_places', -1) > 0 or self._allows_null(attributes):
                    # Check type (float)
                    errors.extend(self._validate_dtype(data, feature, 'float64',
                                                       coerced_df, coerce=coerce))
                elif attributes.get('decimal_places', -1) == 0:
                    # Check type (int)
                    errors.extend(self._validate_dtype(data, feature, 'int64',
                                                       coerced_df, coerce=coerce))
            # Check feature bounds
            if validate_bounds:
                errors.extend(self._validate_bounds(data, feature, attributes))

        if errors:
            msg = ('Failed to validate DataFrame against feature attributes due to the '
                   'following errors:\n')
            for error in errors:
                msg = msg + f'{error}\n'
            if raise_errors:
                raise ValueError(msg)
            else:
                warnings.warn(msg)

        if coerce:
            return coerced_df

    @abstractmethod
    def validate(data: Any, coerce=False, raise_errors=False, validate_bounds=True,
                 allow_missing_features=False, localize_datetimes=True):
        """
        Validate the given data against this FeatureAttributes object.

        Check that feature bounds and data types loosely describe the data. Optionally
        attempt to coerce the data into conformity.
        Parameters
        ----------
        data : Any
            The data to validate
        coerce : bool (default False)
            Whether to attempt to coerce DataFrame columns into correct data types. Coerced
            datetimes will be localized to UTC.
        raise_errors : bool (default False)
            If True, raises a ValueError if nonconforming columns are found; else issue a warning
        validate_bounds : bool (default True)
            Whether to validate the data against the attributes' inferred bounds
        allow_missing_features : bool (default False)
            Allows features that are missing from the DataFrame to be ignored
        localize_datetimes : bool (default True)
            Whether to localize datetime features to UTC.

        Returns
        -------
        None | DataFrame
            None or the coerced DataFrame if 'coerce' is True and there were no errors.
        """
        raise NotImplementedError()


class MultiTableFeatureAttributes(FeatureAttributesBase):
    """A dict-like object containing feature attributes for multiple tables."""

    pass


class SingleTableFeatureAttributes(FeatureAttributesBase):
    """A dict-like object containing feature attributes for a single table or DataFrame."""

    @singledispatchmethod
    def validate(data: Any, **kwargs):
        """
        Validate the given single table data against this FeatureAttributes object.

        Check that feature bounds and data types loosely describe the data. Optionally
        attempt to coerce the data into conformity.
        Parameters
        ----------
        data : Any
            The data to validate (single table only).
        coerce : bool (default False)
            Whether to attempt to coerce DataFrame columns into correct data types.
        raise_errors : bool (default False)
            If True, raises a ValueError if nonconforming columns are found; else, issue a warning.
        validate_bounds : bool (default True)
            Whether to validate the data against the attributes' inferred bounds.
        allow_missing_features : bool (default False)
            Allows features that are missing from the DataFrame to be ignored.
        localize_datetimes : bool (default True)
            Whether to localize datetime features to UTC.

        Returns
        -------
        None | DataFrame
            None or the coerced DataFrame if 'coerce' is True and there were no errors.
        """
        raise NotImplementedError("'data' is an unsupported type")

    @validate.register
    def _(self, data: pd.DataFrame, coerce=False, raise_errors=False, validate_bounds=True,
          allow_missing_features=False, localize_datetimes=True):
        return self._validate_df(data, coerce=coerce, raise_errors=raise_errors,
                                 validate_bounds=validate_bounds,
                                 allow_missing_features=allow_missing_features,
                                 localize_datetimes=localize_datetimes)

    def has_unsupported_data(self, feature_name: str) -> bool:
        """
        Returns whether the given feature has data that is unsupported by Howso Engine.

        Parameters
        ----------
        feature_name: str
            The feature to check.

        Returns
        -------
        bool
            Whether feature_name was determined to have unsupported data.
        """
        return feature_name in self.unsupported


class InferFeatureAttributesBase(ABC):
    """
    This is an abstract Feature Attributes inferrer base class.

    It is agnostic to the type of data being inspected.
    """

    def _process(self,  # noqa: C901
                 features: Optional[Dict[str, Dict]] = None,
                 infer_bounds: bool = True,
                 dropna: bool = False,
                 tight_bounds: Optional[Iterable[str]] = None,
                 mode_bound_features: Optional[Iterable[str]] = None,
                 id_feature_name: Optional[Union[str, Iterable[str]]] = None,
                 attempt_infer_extended_nominals: bool = False,
                 nominal_substitution_config: Optional[Dict[str, Dict]] = None,
                 include_extended_nominal_probabilities: Optional[bool] = False,
                 datetime_feature_formats: Optional[Dict] = None,
                 ordinal_feature_values: Optional[Dict[str, List[str]]] = None,
                 dependent_features: Optional[Dict[str, List[str]]] = None,
                 ) -> Dict:
        """
        Get inferred feature attributes for the parameters.

        See `infer_feature_attributes` for full docstring.
        """
        if isinstance(tight_bounds, bool):
            warnings.warn("Using booleans with 'tight_bounds' is deprecated. Please "
                          "specify the features you would like tight bounds for as an "
                          "iterable of strings.", DeprecationWarning)
            if tight_bounds:
                tight_bounds = self._get_feature_names()
            else:
                tight_bounds = None

        if features and not isinstance(features, dict):
            raise ValueError(
                f"The parameter `features` needs to be a `dict` and not of "
                f"type {type(features)}."
            )

        if features:
            feature_attributes: Dict = serialize_openapi_models(features)
        else:
            feature_attributes = dict()

        if datetime_feature_formats is None:
            datetime_feature_formats = dict()

        if ordinal_feature_values is None:
            ordinal_feature_values = dict()

        if dependent_features is None:
            dependent_features = dict()

        feature_names_list = self._get_feature_names()
        for feature_name in feature_names_list:
            # What type is this feature?
            feature_type, typing_info = self._get_feature_type(feature_name)

            # EXPLICITLY DECLARED ORDINALS
            if feature_name in ordinal_feature_values:
                feature_attributes[feature_name] = {
                    'type': 'ordinal',
                    'bounds': {'allowed': ordinal_feature_values[feature_name]}
                }

            # EXPLICITLY DECLARED DATETIME FEATURES
            elif datetime_feature_formats.get(feature_name, None):
                # datetime_feature_formats is expected to either be only a
                # single string (format) or a tuple of strings (format, locale)
                user_dt_format = datetime_feature_formats[feature_name]
                if features and 'date_time_format' in features.get(feature_name, {}):
                    warnings.warn(f'Warning: date_time_format for {feature_name} '
                                  'provided in both `features` (ignored) and '
                                  '`datetime_feature_formats`.')
                    del features[feature_name]['date_time_format']

                if feature_type == FeatureType.DATETIME:
                    # When feature is a datetime instance, we won't need to
                    # parse the datetime from a string using a custom format.
                    feature_attributes[feature_name] = (
                        self._infer_datetime_attributes(feature_name))
                    warnings.warn(
                        'Providing a datetime feature format for the feature '
                        f'"{feature_name}" is not necessary because the data '
                        'is already formatted as a datetime object. This '
                        'custom format will be ignored.')
                elif feature_type == FeatureType.DATE:
                    # When feature is a date instance, we won't need to
                    # parse the datetime from a string using a custom format.
                    feature_attributes[feature_name] = (
                        self._infer_date_attributes(feature_name))
                    warnings.warn(
                        'Providing a datetime feature format for the feature '
                        f'"{feature_name}" is not necessary because the data '
                        'is already formatted as a date object. This custom '
                        'format will be ignored.')
                elif feature_type == FeatureType.TIME:
                    # When feature is a time instance, we won't need to
                    # parse the datetime from a string using a custom format.
                    feature_attributes[feature_name] = (
                        self._infer_datetime_attributes(feature_name))
                    warnings.warn(
                        'Time only features with a datetime feature format '
                        'will be treated as a datetime using the date '
                        '1970-1-1.', UserWarning)
                elif isinstance(user_dt_format, str):
                    # User passed only the format string
                    feature_attributes[feature_name] = {
                        'type': 'continuous',
                        'date_time_format': user_dt_format,
                    }
                elif (
                    isinstance(user_dt_format, Collection) and
                    len(user_dt_format) == 2
                ):
                    # User passed format string and a locale string
                    dt_format, dt_locale = user_dt_format
                    feature_attributes[feature_name] = {
                        'type': 'continuous',
                        'date_time_format': dt_format,
                        'locale': dt_locale,
                    }
                else:
                    # Not really sure what they passed.
                    raise TypeError(
                        f'The value passed (`{user_dt_format}`) to '
                        f'`datetime_feature_formats` for feature "{feature_name}"'
                        f'is invalid. It should be either a single string '
                        f'(format), or a tuple of 2 strings (format, locale).')

            # FLOATING POINT FEATURES
            elif feature_type == FeatureType.NUMERIC:
                feature_attributes[feature_name] = (
                    self._infer_floating_point_attributes(feature_name))

            # IMPLICITLY DEFINED DATETIME FEATURES
            elif feature_type == FeatureType.DATETIME:
                feature_attributes[feature_name] = (
                    self._infer_datetime_attributes(feature_name))

            # DATE ONLY FEATURES
            elif feature_type == FeatureType.DATE:
                feature_attributes[feature_name] = (
                    self._infer_date_attributes(feature_name))

            # TIME ONLY FEATURES
            elif feature_type == FeatureType.TIME:
                feature_attributes[feature_name] = (
                    self._infer_time_attributes(feature_name))

            # TIMEDELTA FEATURES
            elif feature_type == FeatureType.TIMEDELTA:
                feature_attributes[feature_name] = (
                    self._infer_timedelta_attributes(feature_name))

            # INTEGER FEATURES
            elif feature_type == FeatureType.INTEGER:
                feature_attributes[feature_name] = (
                    self._infer_integer_attributes(feature_name))

            # BOOLEAN FEATURES
            elif feature_type == FeatureType.BOOLEAN:
                feature_attributes[feature_name] = (
                    self._infer_boolean_attributes(feature_name))

            # ALL OTHER FEATURES
            else:
                feature_attributes[feature_name] = (
                    self._infer_string_attributes(feature_name))

            # Is column constrained to be unique?
            if self._has_unique_constraint(feature_name):
                feature_attributes[feature_name]['unique'] = True

            # Add original type to feature
            if feature_type is not None:
                feature_attributes[feature_name]['original_type'] = {
                    'data_type': str(feature_type),
                    **typing_info
                }

            # DECLARED DEPENDENTS
            # First determine if there are any dependent features in the partial features dict
            partial_dependent_features = []
            if features and 'dependent_features' in features.get(feature_name, {}):
                partial_dependent_features = features[feature_name]['dependent_features']
            # Set dependent features: `dependent_features` + partial features dict, if provided
            if feature_name in dependent_features:
                feature_attributes[feature_name]['dependent_features'] = list(
                    set(partial_dependent_features + dependent_features[feature_name])
                )

        if isinstance(id_feature_name, str):
            self._add_id_attribute(feature_attributes, id_feature_name)
        elif isinstance(id_feature_name, Iterable):
            for id_feature in id_feature_name:
                self._add_id_attribute(feature_attributes, id_feature)
        elif id_feature_name is not None:
            raise ValueError('ID feature must be of type `str` or `list[str], '
                             f'not {type(id_feature_name)}.')

        if infer_bounds:
            for feature_name in feature_attributes:
                # Don't infer bounds for manually-specified nominal features
                if features and features.get(feature_name, {}).get('type') == 'nominal':
                    continue
                # Likewise, don't infer bounds for JSON/YAML features
                elif any([
                    feature_attributes[feature_name].get('data_type') in ['json', 'yaml'],
                    features and features.get(feature_name, {}).get('data_type') in ['json', 'yaml']
                ]):
                    continue
                bounds = self._infer_feature_bounds(
                    feature_attributes, feature_name,
                    tight_bounds=tight_bounds,
                    mode_bound_features=mode_bound_features,
                )
                if bounds:
                    feature_attributes[feature_name]['bounds'] = bounds  # noqa

        if dropna:
            for feature_name in feature_attributes:
                if 'dropna' in feature_attributes[feature_name]:
                    # Don't override user's preference
                    continue
                feature_attributes[feature_name]['dropna'] = True  # noqa

        # Do any features contain data unsupported by the core?
        self._check_unsupported_data(feature_attributes)

        # If requested, infer extended nominals.
        if attempt_infer_extended_nominals:
            # Attempt to import the NominalDetectionEngine.
            try:
                from howso.nominal_substitution import (
                    NominalDetectionEngine,
                )
                # Grab whether the user wants the probabilities saved in the feature
                # metadata.
                include_meta = include_extended_nominal_probabilities

                # Get the assigned extended nominal probabilities (aenp) and all
                # probabilities.
                nde = NominalDetectionEngine(nominal_substitution_config)
                aenp, all_probs = nde.detect(self.data)

                nominal_default_subtype = 'int-id'
                # Apply them if they are above the threshold value.
                for feature_name in feature_names_list:
                    if feature_name in aenp:
                        if len(aenp[feature_name]) > 0:
                            feature_attributes[feature_name]['subtype'] = (max(
                                aenp[feature_name], key=aenp[feature_name].get))

                        if include_meta:
                            feature_attributes[feature_name].update({
                                'extended_nominal_probabilities':
                                    all_probs[feature_name]
                            })

                    # If `subtype` is a nominal feature, assign it to 'int-id'
                    if (
                        feature_attributes[feature_name]['type'] == 'nominal' and
                        not feature_attributes[feature_name].get('subtype', None)
                    ):
                        feature_attributes[feature_name]['subtype'] = (
                            nominal_default_subtype)
            except ImportError:
                warnings.warn('Cannot infer extended nominals: not supported')

        # Re-insert any partial features provided as an argument
        if features:
            for feature in features.keys():
                for attribute, value in features[feature].items():
                    feature_attributes[feature][attribute] = value

        return feature_attributes

    @abstractmethod
    def __call__(self) -> FeatureAttributesBase:
        """Process and return the feature attributes."""

    @abstractmethod
    def _infer_floating_point_attributes(self, feature_name: str) -> Dict:
        """Get inferred attributes for the given floating-point column."""

    @abstractmethod
    def _infer_datetime_attributes(self, feature_name: str) -> Dict:
        """Get inferred attributes for the given date-time column."""

    @abstractmethod
    def _infer_date_attributes(self, feature_name: str) -> Dict:
        """Get inferred attributes for the given date only column."""

    @abstractmethod
    def _infer_time_attributes(self, feature_name: str) -> Dict:
        """Get inferred attributes for the given time column."""

    @abstractmethod
    def _infer_timedelta_attributes(self, feature_name: str) -> Dict:
        """Get inferred attributes for the given timedelta column."""

    @abstractmethod
    def _infer_boolean_attributes(self, feature_name: str) -> Dict:
        """Get inferred attributes for the given boolean column."""

    @abstractmethod
    def _infer_integer_attributes(self, feature_name: str) -> Dict:
        """Get inferred attributes for the given integer column."""

    @abstractmethod
    def _infer_string_attributes(self, feature_name: str) -> Dict:
        """Get inferred attributes for the given string column."""

    @abstractmethod
    def _infer_unknown_attributes(self, feature_name: str) -> Dict:
        """Get inferred attributes for the given unknown-type column."""

    @abstractmethod
    def _infer_feature_bounds(
        self,
        feature_attributes: Mapping[str, Mapping],
        feature_name: str,
        tight_bounds: Optional[Iterable[str]] = None,
        mode_bound_features: Optional[Iterable[str]] = None,
    ) -> Optional[Dict]:
        """
        Return inferred bounds for the given column.

        Features with datetimes are converted to seconds since epoch and their
        bounds are calculated accordingly. Features with timedeltas are
        converted to total seconds.

        Parameters
        ----------
        data : Any
            Input data
        feature_attributes : dict
            A dictionary of feature names to a dictionary of parameters.
        feature_name : str
            The name of feature to infer bounds for.
        tight_bounds: Iterable of str, default None
            Set tight min and max bounds for the features specified in
            the Iterable.
        mode_bound_features : list of str, optional
            Explicit list of feature names that should use mode bounds. When
            None, uses all features.

        Returns
        -------
        dict or None
            Dictionary of bounds for the specified feature, or None if no
            bounds.
        """

    @staticmethod
    def infer_loose_feature_bounds(min_bound: Union[int, float],
                                   max_bound: Union[int, float]
                                   ) -> Tuple[float, float]:
        """
        Infer the loose bound values given a tight min and max bound value.

        Parameters
        ----------
        min_bound : int or float
            The minimum value in a dataset for a feature, must be equal to or less
            than the max value
        max_bound : int or float
            The maximum value in a dataset for a feature, must be equal to or more
            than the min value

        Returns
        -------
        Tuple of (min_bound, max_bound) of loose bounds around the provided tight
        min and max_bound bounds
        """
        # NOTE: It was considered to use a smoother bounds-expansion function that
        #       looked like max_loose_bounds = exp(ln(max_bounds) + 0.5), but this
        #       could leak privacy since the actual bounds would be
        #       reverse-engineer-able.
        assert min_bound <= max_bound, \
            "Feature min_bound cannot be larger than max_bound"

        if min_bound < 0:
            # for negative min_bound boundary values:  e^ceil(ln(num))
            min_bound = -np.exp(np.ceil(np.log(-min_bound)))
        elif min_bound > 0:
            # for positive min_bound boundary values:  e^floor(ln(num))
            min_bound = np.exp(np.floor(np.log(min_bound)))

        if max_bound < 0:
            # for negative max_bound boundary values: e^floor(ln(num))
            max_bound = -np.exp(np.floor(np.log(-max_bound)))
        elif max_bound > 0:
            # for positive max_bound boundary values: e^ceil(ln(num))
            max_bound = np.exp(np.ceil(np.log(max_bound)))

        return min_bound, max_bound

    @staticmethod
    def _get_datetime_max():
        # Avoid circular import
        from howso.client.client import get_howso_client_class
        from howso.direct import HowsoDirectClient
        # If on Direct, check the user's platform. Else, default to Unix.
        klass, _ = get_howso_client_class()
        if issubclass(klass, HowsoDirectClient):
            plat = platform.system().lower()
            if plat == 'windows':
                return WIN_DT_MAX
        return LINUX_DT_MAX

    def _check_unsupported_data(self, feature_attributes: Dict) -> None:
        """
        Determine whether any features contain data that is unsupported by the core.

        Unsupported data could be a number or datetime that exceeds the min/max of the core or
        user operating system. If unsupported data is found, add the feature to an internal list
        that indicates which features should be removed before training.

        Parameters
        ----------
        feature_attributes : Dict
            A feature attributes dictionary.
        """
        # Avoid circular import
        from howso.utilities import date_to_epoch
        feature_names = list(feature_attributes.keys())
        for feature_name in feature_names:
            # Check original data type for ints, floats, datetimes
            orig_type = feature_attributes[feature_name]['original_type']['data_type']
            if (orig_type in ['integer', 'numeric'] or 'date_time_format' in
                    feature_attributes[feature_name]):
                # Get feature bounds
                bounds = self._infer_feature_bounds(feature_attributes, feature_name,
                                                    tight_bounds=feature_names)
                if not bounds or bounds.get('min') is None or bounds.get('max') is None:
                    continue
                omit = False
                # Datetimes
                dt_fmt = feature_attributes[feature_name].get('date_time_format')
                if dt_fmt is not None:
                    # Get maximum compatible datetime (depends on platform)
                    allowed_max = date_to_epoch(self._get_datetime_max(), time_format='%Y-%m-%d')
                    actual_max = date_to_epoch(bounds['max'], time_format=dt_fmt)
                    # Verify
                    if actual_max >= allowed_max:
                        omit = True
                else:
                    # Determine the largest absolute value from the feature bounds
                    largest_value = max(abs(bounds['min']), bounds['max'])
                    # Verify integer min/max
                    if orig_type == 'integer':
                        if largest_value >= INTEGER_MAX:
                            omit = True
                    # Verify float min/max
                    elif orig_type == 'numeric':
                        # Determine the smallest absolute value from the feature bounds
                        smallest_value = min(abs(bounds['min']), abs(bounds['max']))
                        if largest_value >= FLOAT_MAX or smallest_value <= FLOAT_MIN:
                            omit = True
                # Keep track of unsupported data internally
                if omit:
                    self.unsupported.append(feature_name)

    @staticmethod
    def _is_datetime(string):
        """
        Return True if string can be interpreted as a date.

        Parameters
        ----------
        string : str
            The string to check.

        Returns
        -------
        True if string is a date, False if not.
        """
        # Return False if string contains only letters.
        if isinstance(string, str) and string.isalpha():
            return False
        try:
            # if the string is a number, it's not a datetime
            float(string)
            return False
        except (TypeError, ValueError):
            pass

        try:
            dt_parse(string)
            return True
        except Exception:  # noqa: Intentionally broad
            return False

    def _is_iso8601_datetime_column(self, feature: str) -> bool:
        """
        Return whether the given feature contains ISO 8601 datetimes.

        Parameters
        ----------
        feature : string
            The feature to check the values of.

        Returns
        -------
        True if the column values can be parsed into an ISO 8601 datetime
        """
        first_non_none = self._get_first_non_null(feature)
        if first_non_none is None:
            return False

        # Pick another value and test if it's also a date to make sure
        # that the first one wasn't a date by accident, for example
        # if the column is 'miscellaneous notes' or 'comment', it's
        # possible for it to have a datetime as the sole value, but the
        # column itself is not actually a datetime type
        rand_val = self._get_random_value(feature, no_nulls=True)

        # Try to parse one or both values as strictly iso8601
        try:
            if not self._is_datetime(first_non_none):
                return False
            isoparse(first_non_none)
            if rand_val is not None:
                if not self._is_datetime(rand_val):
                    return False
                isoparse(rand_val)
        except Exception:  # noqa: Intentionally broad
            return False

        # No issues; it's valid
        return True

    def _is_json_feature(self, feature: str) -> bool:
        """
        Return whether the given feature contains valid JSON.

        Parameters
        ----------
        feature: string
            The feature to check the values of.

        Returns
        -------
        True if the column values can be parsed into JSON.
        """
        first_non_none = self._get_first_non_null(feature)
        if first_non_none is None:
            return False

        # Sample 30 random values
        for _ in range(30):
            rand_val = self._get_random_value(feature, no_nulls=True)
            if rand_val is None:
                return False

            # Try to parse rand_val as JSON
            try:
                if all([
                    '{' not in rand_val and '}' not in rand_val,
                    '[' not in rand_val and ']' not in rand_val,
                ]):
                    return False
                json.loads(rand_val)
            except (TypeError, json.JSONDecodeError):
                return False

        # No exception: valid JSON
        return True

    def _is_yaml_feature(self, feature: str) -> bool:
        """
        Return whether the given feature contains valid YAML.

        Parameters
        ----------
        feature: string
            The feature to check the values of.

        Returns
        -------
        True if the column values can be parsed into YAML.
        """
        first_non_none = self._get_first_non_null(feature)
        if first_non_none is None:
            return False

        # Sample 30 random values
        for _ in range(30):
            rand_val = self._get_random_value(feature, no_nulls=True)
            if rand_val is None:
                return False

            # Try to parse rand_val as YAML
            try:
                yaml.safe_load(rand_val)
                if len(rand_val.split(':')) <= 1 or '\n' not in rand_val:
                    return False
            except (yaml.YAMLError):
                return False

        # No exception: valid YAML
        return True

    @staticmethod
    def _add_id_attribute(feature_attributes: Mapping,
                          id_feature_name: str
                          ) -> None:
        """Update the given feature_attributes in-place for id_features."""
        if id_feature_name in feature_attributes:
            feature_attributes[id_feature_name]['id_feature'] = True
            # If id feature was inferred to be continuous, change it to nominal
            # with 'data_type':number attribute to prevent string conversion.
            if feature_attributes[id_feature_name]['type'] == 'continuous':
                feature_attributes[id_feature_name]['type'] = 'nominal'
                feature_attributes[id_feature_name]['data_type'] = 'number'
                if 'decimal_places' in feature_attributes[id_feature_name]:
                    del feature_attributes[id_feature_name]['decimal_places']

    @classmethod
    def _get_min_max_number_size_bounds(
        cls, feature_attributes: Mapping,
        feature_name: str
    ) -> Tuple[Optional[numbers.Number], Optional[numbers.Number]]:
        """
        Get the minimum and maximum size bounds for a numeric feature.

        The minimum and maximum value is based on the storage size of the
        number obtained from the "original_type" feature attribute, i.e. for a
        8bit integer: min=-128, max=127.

        .. NOTE::
            Bounds will not be returned for 64bit floats since this is the
            maximum supported numeric size, so no bounds are necessary.

        Parameters
        ----------
        feature_attributes : dict
            A dictionary of feature names to a dictionary of parameters.
        feature_name : str
            The name of feature.

        Returns
        -------
        Number or None
            The minimum size.
        Number or None
            The maximum size.
        """
        try:
            original_type = feature_attributes[feature_name]['original_type']
        except (TypeError, KeyError):
            # Feature not found or original typing info not defined
            return None, None

        min_value = None
        max_value = None
        if original_type and original_type.get('size'):
            size = original_type.get('size')
            data_type = original_type.get('data_type')

            if size in [1, 2, 4, 8]:
                if data_type == FeatureType.INTEGER.value:
                    if original_type.get('unsigned'):
                        dtype_info = np.iinfo(f'uint{size * 8}')
                    else:
                        dtype_info = np.iinfo(f'int{size * 8}')
                elif data_type == FeatureType.NUMERIC.value and size < 8:
                    dtype_info = np.finfo(f'float{size * 8}')
                else:
                    # Not a numeric feature or is 64bit float
                    return None, None

                min_value = dtype_info.min
                max_value = dtype_info.max
            elif size == 3 and data_type == FeatureType.INTEGER.value:
                # Some database dialects support 24bit integers
                if original_type.get('unsigned'):
                    min_value = 0
                    max_value = 16777215
                else:
                    min_value = -8388608
                    max_value = 8388607

        return min_value, max_value

    @abstractmethod
    def _get_feature_type(self, feature_name: str
                          ) -> Tuple[Optional[FeatureType], Optional[Dict]]:
        """
        Return the type information for a given feature.

        Parameters
        ----------
        feature_name : str
            The name of the feature to get the type of

        Returns
        -------
        FeatureType or None
            The feature type or None if the column could not be found.
        Dict or None
            Additional typing information about the feature or None if the
            column could not be found.
        """

    @abstractmethod
    def _get_random_value(self, feature_name: str, no_nulls: bool = False) -> Any:
        """Retrieve a random value from the data."""

    @abstractmethod
    def _has_unique_constraint(self, feature_name: str) -> bool:
        """Return whether this feature has a unique constraint."""

    @abstractmethod
    def _get_first_non_null(self, feature_name: str) -> Optional[Any]:
        """
        Get the first non-null value in the given column.

        NOTE: "first" means arbitrarily the first one that the DataFrame or database
              returned; there is no implication of ordering.
        """

    @abstractmethod
    def _get_num_features(self) -> int:
        """Get the number of features/columns in the data."""

    @abstractmethod
    def _get_num_cases(self) -> int:
        """Get the number of cases/rows in the data."""

    @abstractmethod
    def _get_feature_names(self) -> List[str]:
        """Get the names of the features/columns of the data."""

howso/utilities/feature_attributes/base.py
howsoai/howso-engine-py
from typing import Iterable, Union

import pandas as pd

from .base import FeatureAttributesBase
from .pandas import InferFeatureAttributesDataFrame
from .protocols import SQLRelationalDatastoreProtocol, TableNameProtocol
from .relational import InferFeatureAttributesSQLDatastore
from .time_series import InferFeatureAttributesTimeSeries


def infer_feature_attributes(data: Union[pd.DataFrame, SQLRelationalDatastoreProtocol], *,
                             tables: Iterable[TableNameProtocol] = None,
                             time_feature_name: str = None, **kwargs) -> FeatureAttributesBase:
    """
    Return a dict-like feature attributes object with useful accessor methods.

    The returned object is a subclass of FeatureAttributesBase that is appropriate for the
    provided data type.

    Parameters
    ----------
    data : Any
        The data source to infer feature attributes from. Must be a supported data type.

    tables : Iterable of TableNameProtocol
        (Optional, required for datastores) An Iterable of table names to
        infer feature attributes for.

        If included, feature attributes will be generated in the form
        ``{table_name: {feature_attribute: value}}``.

    time_feature_name : str, default None
        (Optional, required for time series) The name of the time feature.

    features : dict or None, default None
        (Optional) A partially filled features dict. If partially filled
        attributes for a feature are passed in, those parameters will be
        retained as is and the rest of the attributes will be inferred.

        For example:
            >>> from pprint import pprint
            >>> df.head(2)
            ... sepal-length  sepal-width  petal-length  petal-width  target
            ... 0           6.7          3.0           5.2          2.3       2
            ... 1           6.0          2.2           5.0          1.5       2
            >>> # Partially filled features dict
            >>> partial_features = {
            ...     "sepal-length": {
            ...         "type": "continuous",
            ...         'bounds': {
            ...             'min': 2.72,
            ...             'max': 3,
            ...             'allow_null': True
            ...         },
            ...     },
            ...     "sepal-width": {
            ...         "type": "continuous"
            ...     }
            ... }
            >>> # Infer rest of the attributes
            >>> features = infer_feature_attributes(
            ...     df, features=partial_features
            ... )
            >>> # Inferred Feature dictionary
            >>> pprint(features)
            ... {
            ...     'sepal-length', {
            ...         'bounds': {
            ...             'allow_null': True, 'max': 3, 'min': 2.72
            ...         },
            ...         'type': 'continuous'
            ...     },
            ...     'sepal-width', {
            ...         'bounds': {
            ...             'allow_null': True, 'max': 7.38905609893065,
            ...             'min': 1.0
            ...         },
            ...         'type': 'continuous'
            ...     },
            ...     'petal-length', {
            ...         'bounds': {
            ...             'allow_null': True, 'max': 7.38905609893065,
            ...             'min': 1.0
            ...         },
            ...         'type': 'continuous'
            ...     },
            ...     'petal-width', {
            ...         'bounds': {
            ...             'allow_null': True, 'max': 2.718281828459045,
            ...             'min': 0.049787068367863944
            ...         },
            ...         'type': 'continuous'
            ...     },
            ...     'target', {
            ...         'bounds': {'allow_null': True},
            ...         'type': 'nominal'
            ...     }
            ... }

        Note that valid 'data_type' values for both nominal and continuous types are:
        'string', 'number', 'json', 'amalgam', and 'yaml'.
        The 'boolean' data_type is valid only when type is nominal.
        'string_mixable' is valid only when type is continuous (predicted values may result in
        interpolated strings containing a combination of characters from multiple original values).

    infer_bounds : bool, default True
        (Optional) If True, bounds will be inferred for the features if the
        feature column has at least one non NaN value

    datetime_feature_formats : dict, default None
        (Optional) Dict defining a custom (non-ISO8601) datetime format and
        an optional locale for features with datetimes.  By default datetime
        features are assumed to be in ISO8601 format.  Non-English datetimes
        must have locales specified.  If locale is omitted, the default
        system locale is used. The keys are the feature name, and the values
        are a tuple of date time format and locale string.

        Example::

            {
                "start_date": ("%Y-%m-%d %A %H.%M.%S", "es_ES"),
                "end_date": "%Y-%m-%d"
            }

    delta_boundaries : dict, default None
        (Optional) For time series, specify the delta boundaries in the form
        {"feature" : {"min|max" : {order : value}}}. Works with partial values
        by specifying only particular order of derivatives you would like to
        overwrite. Invalid orders will be ignored.

        Examples::

            {
                "stock_value": {
                    "min": {
                        '0' : 0.178,
                        '1': 3.4582e-3,
                        '2': None
                    }
                }
            }

    derived_orders : dict, default None
        (Optional) Dict of features to the number of orders of derivatives
        that should be derived instead of synthesized. For example, for a
        feature with a 3rd order of derivative, setting its derived_orders
        to 2 will synthesize the 3rd order derivative value, and then use
        that synthed value to derive the 2nd and 1st order.

    dropna : bool, default False
        (Optional) If True, all features will be populated with `'dropna':
        True` parameter. That would mean, rows containing NaNs will be
        automatically dropped when you train.

    lags : list or dict, default None
        (Optional) A list containing the specific indices of the desired lag
        features to derive for each feature (not including the series time
        feature). Specifying derived lag features for the feature specified by
        time_feature_name must be done using a dictionary. A dictionary can be
        used to specify a list of specific lag  indices for specific features.
        For example: {"feature1": [1, 3, 5]} would derive three different
        lag features for feature1. The resulting lag features hold values
        1, 3, and 5 timesteps behind the current timestep respectively.

        .. note ::
            Using the lags parameter will override the num_lags parameter per
            feature

        .. note ::
            A lag feature is a feature that provides a "lagging value" to a
            case by holding the value of a feature from a previous timestep.
            These lag features allow for cases to hold more temporal information.

    num_lags : int or dict, default None
        (Optional) An integer specifying the number of lag features to
        derive for each feature (not including the series time feature).
        Specifying derived lag features for the feature specified by
        time_feature_name must be done using a dictionary. A dictionary can be
        used to specify numbers of lags for specific features. Features that
        are not specified will default to 1 lag feature.

        .. note ::
            The num_lags parameter will be overridden by the lags parameter per
            feature.

    orders_of_derivatives : dict, default None
        (Optional) Dict of features and their corresponding order of
        derivatives for the specified type (delta/rate). If provided will
        generate the specified number of derivatives and boundary values. If
        set to 0, will not generate any delta/rate features. By default all
        continuous features have an order value of 1.

    rate_boundaries : dict, default None
        (Optional) For time series, specify the rate boundaries in the form
        {"feature" : {"min|max" : {order : value}}}. Works with partial values
        by specifying only particular order of derivatives you would like to
        overwrite. Invalid orders will be ignored.

        Examples::

            {
                "stock_value": {
                    "min": {
                        '0' : 0.178,
                        '1': 3.4582e-3,
                        '2': None
                    }
                }
            }

    tight_bounds: Iterable of str, default None
        (Optional) Set tight min and max bounds for the features
        specified in the Iterable.

    tight_time_bounds : bool, default False
        (optional) If True, will set tight bounds on time_feature.
        This will cause the bounds for the start and end times set
        to the same bounds as observed in the original data.

    time_series_type_default : str, default 'rate'
        (Optional) Type specifying how time series is generated.
        One of 'rate' or 'delta', default is 'rate'. If 'rate',
        it uses the difference of the current value from its
        previous value divided by the change in time since the
        previous value. When 'delta' is specified, just uses
        the difference of the current value from its previous value
        regardless of the elapsed time.

    time_series_types_override : dict, default None
        (Optional) Dict of features and their corresponding time series type,
        one of 'rate' or 'delta', used to override time_series_type_default
        for the specified features.

    mode_bound_features : list of str, default None
        (Optional) Explicit list of feature names to use mode bounds for
        when inferring loose bounds. If None, assumes all features. A mode
        bound is used instead of a loose bound when the mode for the
        feature is the same as an original bound, as it may represent an
        application-specific min/max.

    id_feature_name : str or list of str, default None
        (Optional) The name(s) of the ID feature(s).

    time_invariant_features : list of str, default None
        (Optional) Names of time-invariant features.

    attempt_infer_extended_nominals : bool, default False
        (Optional) If set to True, detections of extended nominals will be
        attempted. If the detection fails, the categorical variables will
        be set to `int-id` subtype.

        .. note ::
            Please refer to `kwargs` for other parameters related to
            extended nominals.

    nominal_substitution_config : dict of dicts, default None
        (Optional) Configuration of the nominal substitution engine
        and the nominal generators and detectors.

    include_extended_nominal_probabilities : bool, default False
        (Optional) If true, extended nominal probabilities will be appended
        as metadata into the feature object.

    datetime_feature_formats : dict, default None
        (optional) Dict defining a custom (non-ISO8601) datetime format and
        an optional locale for columns with datetimes.  By default datetime
        columns are assumed to be in ISO8601 format.  Non-English datetimes
        must have locales specified.  If locale is omitted, the default
        system locale is used. The keys are the column name, and the values
        are a tuple of date time format and locale string:

        Example::

            {
                "start_date" : ("%Y-%m-%d %A %H.%M.%S", "es_ES"),
                "end_date" : "%Y-%m-%d"
            }

    ordinal_feature_values : dict, default None
        (optional) Dict for ordinal string features defining an ordered
        list of string values for each feature, ordered low to high. If
        specified will set 'type' to be 'ordinal' for all features in
        this map.

        Example::

            {
                "grade" : [ "F", "D", "C", "B", "A" ],
                "size" : [ "small", "medium", "large", "huge" ]
            }

    dependent_features: dict, default None
        (Optional) Dict of features with their respective lists of features
        that either the feature depends on or are dependent on them. Should
        be used when there are multi-type value features that tightly
        depend on values based on other multi-type value features.

        Examples:
            If there's a feature name 'measurement' that contains
            measurements such as BMI, heart rate and weight, while the
            feature 'measurement_amount' contains the numerical values
            corresponding to the measurement, dependent features could be
            passed in as follows:

            .. code-block:: json

                {
                    "measurement": [ "measurement_amount" ]
                }

            Since dependence directionality is not important, this will
            also work:

            .. code-block:: json

                {
                    "measurement_amount": [ "measurement" ]
                }

    Returns
    -------
    FeatureAttributesBase
        A subclass of FeatureAttributesBase (Single/MultiTableFeatureAttributes)
        that extends `dict`, thus providing dict-like access to feature
        attributes and useful accessor methods.

    Examples
    --------
    .. code-block:: python

        # 'data' is a DataFrame
        >> attrs = infer_feature_attributes(data)
        # Can access feature attributes like a dict
        >> attrs
            {
                "feature_one": {
                    "type": "continuous",
                    "bounds": {"allow_null": True},
                },
                "feature_two": {
                    "type": "nominal",
                }
            }
        >> attrs["feature_one"]
            {
                "type": "continuous",
                "bounds": {"allow_null": True}
            }
        # Or can call methods to do other stuff
        >> attrs.get_parameters()
            {'dropna': True}

        # Now 'data' is an object that implements SQLRelationalDatastoreProtocol
        >> attrs = infer_feature_attributes(data, tables)
        >> attrs
            {
                "table_1": {
                    "feature_one": {
                        "type": "continuous",
                        "bounds": {"allow_null": True},
                    },
                    "feature_two": {
                        "type": "nominal",
                    }
                },
                "table_2" : {...},
            }
        >> attrs.to_json()
            '{"table_1" : {...}}'

    """
    # Check if time series attributes should be calculated
    if time_feature_name and isinstance(data, pd.DataFrame):
        infer = InferFeatureAttributesTimeSeries(data, time_feature_name)
    elif time_feature_name:
        raise ValueError("'time_feature_name' was included, but 'data' must be of type DataFrame "
                         "for time series feature attributes to be calculated.")
    # Else, check data type
    elif isinstance(data, pd.DataFrame):
        infer = InferFeatureAttributesDataFrame(data)
    elif isinstance(data, SQLRelationalDatastoreProtocol):
        if tables is None:
            raise TypeError("'tables' is a required parameter if 'data' implements "
                            "SQLRelationalDatastoreProtocol.")
        infer = InferFeatureAttributesSQLDatastore(data, tables)
    else:
        raise NotImplementedError('Data not recognized as a DataFrame or compatible datastore.')

    return infer(**kwargs)

howso/utilities/feature_attributes/infer_feature_attributes.py
howsoai/howso-engine-py
import datetime
import decimal
import logging
from math import isnan
from typing import (
    Any, Dict, Iterable, List, Mapping, Optional, Tuple
)
import warnings

from dateutil.parser import parse as dt_parse
import numpy as np
import pandas as pd
from pandas.core.dtypes.common import (
    is_bool_dtype,
    is_datetime64_any_dtype,
    is_float_dtype,
    is_integer_dtype,
    is_timedelta64_dtype,
    is_unsigned_integer_dtype,
)
import pytz

from .base import InferFeatureAttributesBase, SingleTableFeatureAttributes
from ..features import FeatureType
from ..utilities import (
    date_to_epoch,
    determine_iso_format,
    epoch_to_date,
    ISO_8601_DATE_FORMAT,
    ISO_8601_FORMAT,
    time_to_seconds,
)

logger = logging.getLogger(__name__)

SMALLEST_TIME_DELTA = 0.001


class InferFeatureAttributesDataFrame(InferFeatureAttributesBase):
    """Support inferring feature attributes for Pandas DataFrames."""

    def __init__(self, data: pd.DataFrame):
        """
        Instantiate this InferFeatureAttributesDataFrame object.

        Parameters
        ----------
        data : pd.DataFrame
            The DataFrame containing the features whose attributes will be inferred.
        """
        self.data = data
        # Keep track of features that contain unsupported data
        self.unsupported = []

    def __call__(self, **kwargs) -> SingleTableFeatureAttributes:
        """Process and return feature attributes."""
        return SingleTableFeatureAttributes(self._process(**kwargs), params=kwargs,
                                            unsupported=self.unsupported)

    def _get_num_features(self) -> int:
        return self.data.shape[1]

    def _get_num_cases(self) -> int:
        return self.data.shape[0]

    def _get_feature_names(self) -> List[str]:
        feature_names = self.data.columns.tolist()
        for name in feature_names:
            if not isinstance(name, str):
                raise ValueError(
                    "Unexpected DataFrame column name format, please update "
                    "your DataFrame to use string column names.")
        return feature_names

    def _has_unique_constraint(self, feature_name: str) -> bool:
        # This always returns False for DataFrames, which don't support such
        # constraints.
        return False

    def _get_feature_type(self, feature_name: str  # noqa: C901
                          ) -> Tuple[Optional[FeatureType], Optional[Dict]]:
        # Import this here to avoid circular import
        from howso.client.exceptions import HowsoError
        feature = self.data[feature_name]
        if feature is not None:
            if isinstance(feature, pd.DataFrame):
                raise ValueError(
                    "The provided DataFrame contains duplicate column names. "
                    "All column names must be unique.")
            dtype = feature.dtype
            if is_float_dtype(dtype):
                typing_info = {}
                if itemsize := getattr(dtype, 'itemsize', None):
                    if itemsize > 8:
                        raise HowsoError(
                            f'Unsupported data type "{dtype}" found for '
                            f'feature "{feature_name}", Howso does not '
                            'currently support numbers larger than 64-bit.')
                    typing_info['size'] = itemsize

                return FeatureType.NUMERIC, typing_info

            elif is_integer_dtype(dtype):
                typing_info = {}
                if itemsize := getattr(dtype, 'itemsize', None):
                    typing_info['size'] = itemsize
                if is_unsigned_integer_dtype(dtype):
                    typing_info['unsigned'] = True

                return FeatureType.INTEGER, typing_info

            elif is_datetime64_any_dtype(dtype):
                typing_info = {}
                if dtype in ['datetime64[Y]', 'datetime64[M]', 'datetime64[D]']:
                    return FeatureType.DATE, {}
                elif isinstance(dtype, pd.DatetimeTZDtype):
                    if isinstance(dtype.tz, pytz.BaseTzInfo) and dtype.tz.zone:
                        # If using a named time zone capture it, otherwise
                        # rely on the offset in the iso8601 format
                        typing_info['timezone'] = dtype.tz.zone
                return FeatureType.DATETIME, typing_info

            elif is_timedelta64_dtype(dtype):
                # All time deltas will be converted to seconds
                return FeatureType.TIMEDELTA, {'unit': 'seconds'}

            elif is_bool_dtype(dtype):
                return FeatureType.BOOLEAN, {}

            elif self._is_character_dtype(dtype):
                if getattr(dtype, 'kind', None) != 'U':
                    warnings.warn(f'The column "{feature_name}" contained '
                                  'bytes, original encoding of this column '
                                  'is not be guaranteed.')
                return FeatureType.STRING, {}

            else:
                first_non_null = self._get_first_non_null(feature_name)
                if isinstance(first_non_null, str):
                    # DataFrames may use "object" dtype for strings, detect
                    # string columns by checking the type of the data
                    return FeatureType.STRING, {}
                elif isinstance(first_non_null, bytes):
                    warnings.warn(f'The column "{feature_name}" contained '
                                  'bytes, original encoding of this column '
                                  'is not be guaranteed.')
                    return FeatureType.STRING, {}
                elif isinstance(first_non_null, datetime.datetime):
                    return FeatureType.DATETIME, {}
                elif isinstance(first_non_null, datetime.date):
                    return FeatureType.DATE, {}
                elif isinstance(first_non_null, datetime.time):
                    return FeatureType.TIME, {}
                elif isinstance(first_non_null, decimal.Decimal):
                    return FeatureType.NUMERIC, {'format': 'decimal'}
            # Feature is of generic object type
            return FeatureType.UNKNOWN, {}
        else:
            return None, None

    @staticmethod
    def _is_character_dtype(dtype) -> bool:
        """Check if dtype is a subdtype of numpy.character."""
        try:
            # If numpy doesn't recognize the dtype, it will raise
            return np.issubdtype(dtype, np.character)
        except Exception:  # noqa: Deliberately broad
            return False

    def _get_first_non_null(self, feature_name: str) -> Optional[Any]:
        index = self.data[feature_name].first_valid_index()
        if index is None:
            return None
        return self.data[feature_name][index]

    def _get_random_value(self, feature_name: str, no_nulls: bool = False) -> Optional[Any]:
        """
        Return a random sample from the given DataFrame column.

        The return type is determined by the column type.

        if `no_nulls` is set, select a random value from the set of non-null
        values, if any. If there are no such non-nulls, this will return None.
        """
        cases = self.data[feature_name]
        if no_nulls:
            cases = cases.loc[~self.data[feature_name].isnull()]
        if len(cases) <= 1:
            return None
        return cases.iloc[1 + np.random.randint(len(cases) - 1)]

    def _infer_feature_bounds(  # noqa: C901
        self,
        feature_attributes: Mapping[str, Mapping],
        feature_name: str,
        tight_bounds: Optional[Iterable[str]] = None,
        mode_bound_features: Optional[Iterable[str]] = None,
    ) -> Optional[Dict]:
        output = None
        allow_null = True
        column = self.data[feature_name]
        # only integers by default do not allow nulls
        if is_integer_dtype(column.dtype):
            allow_null = False

        def _as_float(value, dtype):
            # Convert datatype to float
            if is_timedelta64_dtype(dtype):
                if pd.isna(value):
                    return float('nan')
                return value.to_pytimedelta().total_seconds()
            elif isinstance(value, datetime.time):
                if pd.isna(value):
                    return float('nan')
                return time_to_seconds(value)
            else:
                return float(value)

        if feature_attributes[feature_name].get('type') == 'continuous':
            column_filtered = self.data[feature_name].dropna()

            if 'date_time_format' in feature_attributes[feature_name]:
                format_dt = feature_attributes[feature_name].get(
                    'date_time_format')
                min_date, max_date = None, None
                min_date_tz, max_date_tz = None, None

                if is_datetime64_any_dtype(self.data[feature_name].dtype):
                    min_date = column_filtered.min()
                    max_date = column_filtered.max()
                else:
                    try:
                        min_date_sec = np.inf
                        max_date_sec = -np.inf
                        # loop over all datetimes to determine the min and max
                        for datetime_val in column_filtered.values:
                            seconds = date_to_epoch(datetime_val, format_dt)
                            if seconds < min_date_sec:
                                min_date_sec = seconds
                                min_date = datetime_val
                            if seconds > max_date_sec:
                                max_date_sec = seconds
                                max_date = datetime_val

                        # If value is a string, parse it into datetime object
                        if isinstance(min_date, str):
                            min_date = dt_parse(min_date)
                        if isinstance(max_date, str):
                            max_date = dt_parse(max_date)
                    except Exception:  # noqa: Intentionally broad
                        w_str = (f'Feature {feature_name} does not match the '
                                 'provided date time format, unable to guess '
                                 'bounds.')
                        warnings.warn(w_str)
                        return None

                if pd.isnull(min_date) or pd.isnull(max_date):
                    return None

                # Capture the timezone information, so it can be included
                # in the conversion back from epoch.
                if isinstance(min_date, (datetime.datetime, datetime.time)):
                    min_date_tz = min_date.tzinfo
                if isinstance(max_date, (datetime.datetime, datetime.time)):
                    max_date_tz = max_date.tzinfo

                try:
                    actual_min_f = min_f = date_to_epoch(min_date, format_dt)
                    actual_max_f = max_f = date_to_epoch(max_date, format_dt)
                    if (
                        tight_bounds is None
                        or feature_name not in tight_bounds
                    ):
                        # Calculate loose bounds
                        min_f, max_f = self.infer_loose_feature_bounds(min_f, max_f)
                        # Check for mode bounds
                        if (
                            mode_bound_features is None or
                            feature_name in mode_bound_features
                        ):
                            col_modes = column_filtered.mode()
                            if len(col_modes) == len(column_filtered):
                                # All values are unique
                                col_modes = pd.Series(dtype=column.dtype)
                        else:
                            col_modes = pd.Series(dtype=column.dtype)

                        if (
                            not col_modes.empty and
                            is_datetime64_any_dtype(column) and
                            is_datetime64_any_dtype(col_modes) and
                            col_modes.dt.tz is None and
                            column.dt.tz is not None
                        ):
                            # Due to a limitation of pandas in python 3.7:
                            # If the mode is returned as timezone unaware but
                            # the original column is timezone aware it has been
                            # returned as the UTC value instead. Convert the
                            # mode to timezone aware so we can correctly detect
                            # the number of instances of it in the original
                            # column.
                            col_modes = col_modes.dt.tz_localize(pytz.utc)
                            col_modes = col_modes.dt.tz_convert(column.dt.tz)

                        # If the mode for the feature is same as an original
                        # bound, set that appropriate bound to the mode value
                        # since in this case, it probably represents an
                        # application-specific min/max. Only applies if there
                        # are more than 3 instances of the value.
                        for mode_value in col_modes:
                            if self.data[column == mode_value].shape[0] > 3:
                                mode_f = date_to_epoch(mode_value, format_dt)
                                if actual_min_f == mode_f:
                                    min_f = actual_min_f
                                elif actual_max_f == mode_f:
                                    max_f = actual_max_f

                    min_date = epoch_to_date(min_f, format_dt, min_date_tz)
                    max_date = epoch_to_date(max_f, format_dt, max_date_tz)
                    return {'min': min_date, 'max': max_date}
                except Exception:  # noqa: Intentionally broad
                    w_str = (f"Feature {feature_name} does not match the "
                             "provided date time format, unable to guess "
                             "bounds.")
                    warnings.warn(w_str)
                    max_f = np.nan
                    min_f = np.nan

            else:
                min_f = _as_float(column_filtered.min(), column.dtype)
                max_f = _as_float(column_filtered.max(), column.dtype)

            if not (isnan(min_f) or isnan(max_f)):
                actual_min_f = min_f
                actual_max_f = max_f
                # set loose bounds if no tight bounds for all and this
                # feature isn't on the tight bounds list
                if (
                    tight_bounds is None
                    or feature_name not in tight_bounds
                ):
                    min_f, max_f = self.infer_loose_feature_bounds(
                        actual_min_f, actual_max_f
                    )
                    # Check for mode bounds
                    if (
                        mode_bound_features is None or
                        feature_name in mode_bound_features
                    ):
                        col_modes = column_filtered.mode()
                        if len(col_modes) == len(column_filtered):
                            # All values are unique
                            col_modes = []

                        # If the mode for the feature is same as an original
                        # bound, set that appropriate bound to the mode value
                        # since in this case, it probably represents an
                        # application-specific min/max. Only applies if there
                        # are more than 3 instances of the value.
                        for mode_value in col_modes:
                            if self.data[column == mode_value].shape[0] > 3:
                                mode_f = _as_float(mode_value, column.dtype)
                                if actual_min_f == mode_f:
                                    min_f = actual_min_f
                                elif actual_max_f == mode_f:
                                    max_f = actual_max_f

                output = {'min': min_f, 'max': max_f}
                if not allow_null:
                    output['allow_null'] = False
            else:
                # If no min/max were found from the data, use min/max size of
                # the data type.
                min_value, max_value = self._get_min_max_number_size_bounds(
                    feature_attributes, feature_name)
                if min_value is not None and max_value is not None:
                    output = {'min': min_value, 'max': max_value}

        else:  # Ordinals
            if not allow_null:
                output = {'allow_null': False}

        return output

    def _infer_floating_point_attributes(self, feature_name: str) -> Dict:
        attributes: Dict[str, Any] = {"type": "continuous"}

        n_cases = self.data[feature_name].shape[0]

        # Ensure we have at least one valid value before attempting to
        # introspect further.
        if self.data[feature_name].isna().sum() < n_cases:

            # determine if nominal by checking if number of uniques <= 2
            if self.data[feature_name].nunique() <= 2 and n_cases > 10:
                return {
                    "type": "nominal",
                    "data_type": "number"
                }

            # Determine number of decimal places
            # If series > 1000 subset
            if len(self.data[feature_name]) > 1000:
                ind = np.random.randint(0, len(self.data[feature_name]), size=1000)
                col = self.data[feature_name].iloc[ind]

                # Remove Inf, -Inf values
                col = col.replace([np.inf, -np.inf], np.nan)

                # Check to see if / how many are NaNs
                nan_ratio = col.isna().sum() / len(col)

                # If NaN ratio > 0.8 then drop the NaN first and re-sample
                if nan_ratio > 0.8:
                    infinities = [np.inf, -np.inf]
                    col = (self.data[feature_name].replace(infinities, np.nan)
                           .dropna().reset_index(drop=True))
                    ind = np.random.randint(0, col.shape[0], size=1000)
                    col = col.iloc[ind]
                else:
                    col = col.dropna().reset_index(drop=True)

            # If series is less than 1000
            else:
                col = self.data[feature_name]
                col = (col.replace([np.inf, -np.inf], np.nan)
                          .dropna().reset_index(drop=True))

            # Determine number of decimal places using
            # np.format_float_positional to handle scientific notation.
            decimals = max([
                len((str(np.format_float_positional(r))).split('.')[1])
                for r in col
            ])

            # specify decimal place. Proceed with training but issue a warning.
            if pd.api.types.is_float_dtype(col.dtype):
                try:
                    if getattr(col.dtype, 'itemsize') <= 8:
                        attributes['decimal_places'] = decimals
                    else:
                        warnings.warn(
                            f'Feature {feature_name} contains floating point '
                            'values that exceed the maximum supported precision '
                            'of 64 bits.'
                        )
                except AttributeError:
                    warnings.warn(
                        f'Feature {feature_name} may contain floating point '
                        'values that exceed the maximum supported precision '
                        'of 64 bits.'
                    )

        return attributes

    def _infer_datetime_attributes(self, feature_name: str) -> Dict:
        column = self.data[feature_name]
        dt_format = ISO_8601_FORMAT
        if hasattr(column, 'dt') and getattr(column.dt, 'tz', None):
            # Include timezone offset in format
            dt_format += "%z"
        elif column.dtype == object:
            first_non_null = self._get_first_non_null(feature_name)
            if isinstance(first_non_null, datetime.datetime):
                # In the event of mixed timezone values the column dtype will
                # be 'object'. In this case we check if the first non-null
                # value has tzinfo and include the timezone in the format
                if first_non_null.tzinfo is not None:
                    dt_format += "%z"
        return {
            'type': 'continuous',
            'date_time_format': dt_format,
        }

    def _infer_date_attributes(self, feature_name: str) -> Dict:
        return {
            "type": "continuous",
            "date_time_format": ISO_8601_DATE_FORMAT
        }

    def _infer_time_attributes(self, feature_name: str) -> Dict:
        return {
            'type': 'continuous'
        }

    def _infer_timedelta_attributes(self, feature_name: str) -> Dict:
        return {
            "type": "continuous"
        }

    def _infer_boolean_attributes(self, feature_name: str) -> Dict:
        return {
            "type": "nominal",
            "data_type": "boolean",
        }

    def _infer_integer_attributes(self, feature_name: str) -> Dict:
        # Decide if categorical by checking number of uniques is fewer
        # than the square root of the total samples or if every value
        # has exactly the same length.
        num_uniques = self.data[feature_name].nunique()
        n_cases = int(self.data[feature_name].count())
        if num_uniques < pow(n_cases, 0.5):
            guess_nominals = True
        else:
            # Find the largest and smallest non-null values in column.
            try:
                col_min = int(self.data[feature_name].nsmallest(1).iloc[0])
                col_max = int(self.data[feature_name].nlargest(1).iloc[0])
            except TypeError:
                # Column is all None?
                guess_nominals = False
            else:
                # Guess nominals if ALL of:
                #   - `col_min` and `col_max` are both greater than zero
                #   - Their length is at least 5
                #   - They have the same length
                guess_nominals = (
                    col_min > 0 and col_max > 0 and
                    len(str(col_min)) >= 5 and
                    len(str(col_min)) == len(str(col_max))
                )

        if guess_nominals:
            attributes = {
                "type": "nominal",
                "data_type": "number",
                "decimal_places": 0,
            }
        else:
            attributes = {
                "type": "continuous",
                "decimal_places": 0,
            }

        return attributes

    def _infer_string_attributes(self, feature_name: str) -> Dict:
        # Column has arbitrary string values, first check if they
        # are ISO8601 datetimes.
        if self._is_iso8601_datetime_column(feature_name):
            # if datetime, determine the iso8601 format it's using
            if first_non_null := self._get_first_non_null(feature_name):
                fmt = determine_iso_format(first_non_null, feature_name)
                return {
                    "type": "continuous",
                    "date_time_format": fmt
                }
            else:
                # It isn't clear how this method would be called on a feature
                # if it has no data, but just in case...
                return {
                    "type": "continuous",
                }
        elif self._is_json_feature(feature_name):
            return {
                "type": "continuous",
                "data_type": "json"
            }
        elif self._is_yaml_feature(feature_name):
            return {
                "type": "continuous",
                "data_type": "yaml"
            }
        else:
            return self._infer_unknown_attributes(feature_name)

    def _infer_unknown_attributes(self, feature_name: str) -> Dict:
        return {
            "type": "nominal"
        }

howso/utilities/feature_attributes/pandas.py
howsoai/howso-engine-py
from abc import abstractmethod
from typing import (
    Any, Dict, Generator, Iterable, List, Optional, Protocol, runtime_checkable, Tuple, Union
)

import pandas as pd


class TableNameProtocol(Protocol):
    """Protocol for a database table name object."""

    schema: str
    table: str


class SQLTableProtocol(Protocol):
    """Protocol for a SQL table object."""

    c: Dict
    columns: Dict
    name: str
    schema: str


class SessionProtocol(Protocol):
    """Protocol for a sqlalchemy Session object."""

    @abstractmethod
    def commit(self):
        """Flush pending changes and commit the current transaction."""
        raise NotImplementedError

    @abstractmethod
    def close(self):
        """Close out the transactional resources and ORM objects used by this Session."""
        raise NotImplementedError

    @abstractmethod
    def rollback(self):
        """Rollback the current transaction in progress."""
        raise NotImplementedError


class AbstractDataProtocol(Protocol):
    """Protocol for an abstract data file object."""

    @abstractmethod
    def get_row_count(self):
        """Get the number of rows in a file."""
        raise NotImplementedError

    @abstractmethod
    def get_dataframe(self) -> pd.DataFrame:
        """Get the file as a DataFrame."""
        raise NotImplementedError

    @abstractmethod
    def get_group_map(self, column_name: str) -> Dict[Any, int]:
        """Get the group map."""
        raise NotImplementedError

    @abstractmethod
    def get_n_random_rows(self, samples: int, seed: Optional[int]) -> pd.DataFrame:
        """Get a specified number of random rows."""
        raise NotImplementedError

    @abstractmethod
    def write_chunk(self, chunk: pd.DataFrame, *,
                    if_exists: str = "append"
                    ) -> None:
        """Write a chunk."""
        raise NotImplementedError

    @abstractmethod
    def yield_chunk(self, chunk_size: int = 5000, *,
                    max_chunks: Optional[int] = None,
                    skip_chunks: Optional[int] = None,
                    ) -> Generator[pd.DataFrame, None, None]:
        """Provide a chunk generator."""
        raise NotImplementedError

    @abstractmethod
    def yield_grouped_chunk(self, column_name: str,
                            groups: Iterable[Iterable[Any]]
                            ) -> Generator[pd.DataFrame, None, None]:
        """Provide a grouped chunk generator."""
        raise NotImplementedError

    @abstractmethod
    def map_keys(self, chunk: pd.DataFrame) -> pd.DataFrame:
        """Map keys to a chunk."""
        raise NotImplementedError


class RelationshipProtocol(Protocol):
    """Protocol for an object representing a relationship in a database."""

    source: TableNameProtocol
    source_columns: Tuple[str]
    destination: TableNameProtocol
    destination_columns: Tuple[str]


class ComponentProtocol(Protocol):
    """Protocl for an object representing an independent collection of dataframes."""

    datastore: Any
    graph: Any


@runtime_checkable
class DatastoreProtocol(Protocol):
    """Protocol for a datastore object."""

    @abstractmethod
    def items(self) -> Generator[Tuple[TableNameProtocol, AbstractDataProtocol], None, None]:
        """Get items in the datastore."""
        raise NotImplementedError

    @abstractmethod
    def is_degenerate_relationship(self, relationship: RelationshipProtocol,
                                   robust: bool = False) -> bool:
        """Get whether a relationship is degenerate."""
        raise NotImplementedError

    @abstractmethod
    def degenerate_relationships(self, *, robust: bool = False
                                 ) -> Generator[RelationshipProtocol, None, None]:
        """Get a generator of degenerate relationships in this datastore."""
        raise NotImplementedError

    @abstractmethod
    def components(self) -> Generator[ComponentProtocol, None, None]:
        """Get a generator of the components in this datastore."""
        raise NotImplementedError

    @abstractmethod
    def pre_synth_check(self, related_datastore: Any,
                        **kwargs) -> bool:
        """Attempt a pre-synth check."""
        raise NotImplementedError

    @abstractmethod
    def reflect(self, source: Any,
                drop_existing: bool = False) -> None:
        """Do a reflection."""
        raise NotImplementedError

    @abstractmethod
    def has_feature_support(self, feature_key: str) -> bool:
        """Return whether the given feature is supported."""
        raise NotImplementedError

    @abstractmethod
    def get_row_count(self, table_name: TableNameProtocol) -> Optional[int]:
        """Get the number of rows in the specified table."""
        raise NotImplementedError

    @abstractmethod
    def get_data(self, table_name) -> AbstractDataProtocol:
        """Get the data in a specified table."""
        raise NotImplementedError

    @abstractmethod
    def set_data(self, table_name, data: AbstractDataProtocol):
        """Set the data in a specified table."""
        raise NotImplementedError

    @abstractmethod
    def get_values(self,
                   table_name: TableNameProtocol,
                   primary_key_columns: Union[List[str], str],
                   primary_key_values: Union[List[List[Any]], List[Any]],
                   column_name: str) -> List[Any]:
        """Get the column values in a specified table."""
        raise NotImplementedError

    @abstractmethod
    def replace_values(self,
                       table_name: TableNameProtocol,
                       primary_key_columns: Union[List[str], str],
                       primary_key_values: Union[List[Any], Any],
                       column_name: str,
                       replace_values: List[Any],
                       return_old: bool = False
                       ) -> Optional[List[Any]]:
        """Replace the column values in a specified table."""
        raise NotImplementedError


@runtime_checkable
class RelationalDatastoreProtocol(DatastoreProtocol, Protocol):
    """Protocol for a relational datastore object."""

    graph: Any


@runtime_checkable
class SQLRelationalDatastoreProtocol(DatastoreProtocol, Protocol):
    """Protocol for a SQL relational datastore object."""

    engine: int

howso/utilities/feature_attributes/protocols.py
howsoai/howso-engine-py
from contextlib import contextmanager
import datetime
from datetime import time, timedelta
import decimal
import logging
from math import ceil, isnan, log
import re
from typing import (
    Any, Dict, Iterable, List, Mapping, Optional, Tuple, Union
)
import warnings

import numpy as np
import pandas as pd

from .base import InferFeatureAttributesBase, MultiTableFeatureAttributes, SingleTableFeatureAttributes
from .protocols import (
    SessionProtocol,
    SQLRelationalDatastoreProtocol,
    SQLTableProtocol,
    TableNameProtocol,
)
from ..features import FeatureType
from ..utilities import (
    date_to_epoch,
    determine_iso_format,
    ISO_8601_DATE_FORMAT,
    ISO_8601_FORMAT,
    time_to_seconds,
)

logger = logging.getLogger(__name__)

# Import sqlalchemy only if it's available. Not all users of this package will have or need
# this installed.
try:
    import sqlalchemy as db
    from sqlalchemy import func, inspect, MetaData
    from sqlalchemy.orm import sessionmaker
except ImportError:
    db = None

COLUMNS = 'COLUMNS'
COLUMN_TYPE_PATTERN = re.compile(
    r'(?P<type>\w+)\(?(?P<precision>\d+)?,?\s?(?P<scale>\d+)?\)?'
)
FOREIGN_KEYS = 'FOREIGN_KEYS'
IGNORED_FKEYS = 'IGNORED_FKEYS'
PRIMARY_KEYS = 'PRIMARY_KEYS'
REFLEXIVE_FKEYS = 'REFLEXIVE_FKEYS'
UNIQUE_COLUMNS = 'UNIQUE_COLUMNS'


class DatastoreColumnTypes:
    """
    Maps database column types to a data type.

    Note: Some dialects support different configurations of data types.
    """

    DEFAULTS = {
        # Mapping of numeric type to its storage size (in bytes)
        'exact_size_numbers': {
            # Integers
            'TINYINT': 1,
            'SMALLINTEGER': 2,
            'SMALLINT': 2,
            'MEDIUMINTEGER': 3,
            'MEDIUMINT': 3,
            'INTEGER': 4,
            'INT': 4,
            'SERIAL': 4,
            'BIGINTEGER': 8,
            'BIGINT': 8,
            'BIGSERIAL': 8,
            # Floats
            'BINARY_FLOAT': 4,
            'FLOAT': 4,
            'SMALLMONEY': 4,
            'REAL': 4,
            'BINARY_DOUBLE': 8,
            'DOUBLE': 8,
            'MONEY': 8,
            'DOUBLE_PRECISION': 8,
        },
        # Numeric types that have variable storage sizes
        'variable_size_numbers': ['DECIMAL', 'NUMERIC', ],
        # Data types that are always timezone aware
        'tz_aware_date_time_types': [],
        # Data type mappings
        'floating_point': [
            'BINARY_DOUBLE', 'BINARY_FLOAT', 'DECIMAL', 'DOUBLE',
            'DOUBLE_PRECISION', 'FLOAT', 'MONEY', 'NUMERIC', 'REAL',
            'SMALLMONEY',
        ],
        'integer': [
            'BIGINT', 'BIGINTEGER', 'INT', 'INTEGER', 'LONG', 'MEDIUMINT',
            'SMALLINT', 'SMALLINTEGER', 'TINYINT', 'YEAR', 'BIGSERIAL',
            'SERIAL',
        ],
        'boolean': ['BOOLEAN', ],
        'datetime': [
            'DATETIME', 'DATETIME2', 'SMALLDATETIME', 'TIMESTAMP',
        ],
        'date': ['DATE', ],
        'time': ['TIME', ],
        'timedelta': ['INTERVAL', ],
        'string': [
            'BFILE', 'BINARY', 'BIT', 'BLOB', 'BOOLEAN', 'BYTEA', 'CHAR',
            'CIDR', 'CLOB', 'CONCATENABLE', 'ENUM', 'IMAGE', 'INET', 'JSON',
            'JSONB', 'LARGEBINARY', 'LONGBLOB', 'LONGTEXT', 'MACADDR',
            'MEDIUMBLOB', 'MEDIUMTEXT', 'NCHAR', 'NCLOB', 'NTEXT', 'NVARCHAR',
            'OID', 'RAW', 'REGCLASS', 'ROWID', 'STRING', 'TEXT', 'TINYBLOB',
            'TINYTEXT', 'UNICODE', 'UNICODETEXT', 'UNIQUEIDENTIFIER', 'UUID',
            'VARBINARY', 'VARBINARY', 'VARCHAR', 'VARCHAR2', 'XML',
        ],
    }
    DIALECT_OVERRIDES = {
        'mssql': {
            'exact_size_numbers': {'FLOAT': 8, },
            'boolean': ['BIT', ],
            'tz_aware_date_time_types': ['DATETIMEOFFSET', ],
            'datetime': [
                'DATETIME', 'DATETIME2', 'SMALLDATETIME', 'DATETIMEOFFSET',
            ],
        }
    }

    def __init__(self, dialect) -> None:
        """Initialize this DatastoreColumnTypes class and set the dialect."""
        self._dialect = dialect

    def _get_data_types(self, key: str) -> List[str]:
        """
        Get data types by key.

        Parameters
        ----------
        key : str
            The type key.

        Returns
        -------
        list of str or dict
            The supported database column types.
        """
        try:
            return self.DIALECT_OVERRIDES[self._dialect.name][key]
        except (TypeError, KeyError):
            return self.DEFAULTS[key]

    @property
    def variable_size_numbers(self) -> List[str]:
        """Get variable size number column types."""
        return self._get_data_types('variable_size_numbers')

    @property
    def exact_size_numbers(self) -> Dict[str, int]:
        """Get exact size number column types."""
        try:
            overrides = self.DIALECT_OVERRIDES[
                self._dialect.name]['exact_size_numbers']
        except (TypeError, KeyError):
            overrides = {}
        return {
            **self.DEFAULTS['exact_size_numbers'],
            **overrides
        }

    @property
    def floating_point_types(self) -> List[str]:
        """Get floating point column types."""
        return self._get_data_types('floating_point')

    @property
    def integer_types(self) -> List[str]:
        """Get integer column types."""
        return self._get_data_types('integer')

    @property
    def boolean_types(self) -> List[str]:
        """Get boolean column types."""
        return self._get_data_types('boolean')

    @property
    def tz_aware_date_time_types(self) -> List[str]:
        """Get date time column types that are timezone aware."""
        return self._get_data_types('tz_aware_date_time_types')

    @property
    def datetime_types(self) -> List[str]:
        """Get date-time column types."""
        return self._get_data_types('datetime')

    @property
    def date_types(self) -> List[str]:
        """Get date column types."""
        return self._get_data_types('date')

    @property
    def time_types(self) -> List[str]:
        """Get time column types."""
        return self._get_data_types('time')

    @property
    def all_date_time_types(self) -> List[str]:
        """Get all date and time types."""
        return (self._get_data_types('datetime')
                + self._get_data_types('date')
                + self._get_data_types('time'))

    @property
    def timedelta_types(self) -> List[str]:
        """Return timedelta column types."""
        return self._get_data_types('timedelta')

    @property
    def string_types(self) -> List[str]:
        """Return string column types."""
        return self._get_data_types('string')


@contextmanager
def session_scope(session_class):
    """Provide a transactional scope around a series of operations."""
    session = session_class()
    try:
        yield session
        session.commit()
    except Exception:
        logger.exception('Database error')
        session.rollback()
        raise
    finally:
        session.close()


class InferFeatureAttributesSQLTable(InferFeatureAttributesBase):
    """Supports inferring feature attributes for SQL tables."""

    def __init__(self, data: SQLTableProtocol, table_name: TableNameProtocol,
                 column_types: DatastoreColumnTypes,
                 session_cls: SessionProtocol,
                 parent_datastore: SQLRelationalDatastoreProtocol):
        """
        Instantiate this InferFeatureAttributesSQLTable object.

        Parameters
        ----------
        data : SQLTableProtocol
            The SQL table containing the features whose attributes will be inferred.
        table_name : TableNameProtocol
            A TableName object with information about this SQL table.
        column_types : DatastoreColumnTypes
            The datastore column types.
        session_cls : SessionProtocol
            The sqlalchemy Session object associated with this SQL table.
        parent_datastore : SQLRelationalDatastoreProtocol
            The datastore to which this SQL table belongs.
        """
        if not db:
            raise ImportError('Must have sqlalchemy installed to instantiate '
                              'FeatureAttributesSQLTable. See synthesizer-data-services/'
                              'requirements.in for versioning.')
        self.data = data
        self.table_name = table_name
        self.column_types = column_types
        self.session_cls = session_cls
        self.datastore = parent_datastore
        # Keep track of features that contain unsupported data
        self.unsupported = []

    def __call__(self, **kwargs) -> SingleTableFeatureAttributes:
        """Process and return the feature attributes."""
        return SingleTableFeatureAttributes(self._process(**kwargs), kwargs,
                                            unsupported=self.unsupported)

    def _is_primary_key(self, feature_name: str) -> bool:
        """Return True if the given feature_name is a primary key."""
        # TODO nodes[self.data.name] actually needs to be nodes[TableName]
        return (
            feature_name in
            self.datastore.graph.nodes[self.table_name][PRIMARY_KEYS]
        )

    def _is_foreign_key(self, feature_name: str) -> bool:
        """Return True if the given feature_name is a foreign key."""
        # TODO see above todo
        for rel_obj in self.datastore.graph.nodes[self.table_name][FOREIGN_KEYS]:
            if feature_name in rel_obj.source_columns:
                return True

        return False

    def _get_first_non_null(self, feature_name: str) -> Optional[Any]:
        with session_scope(self.session_cls) as session:
            first_non_null = session.query(self.data.c[feature_name]).filter(
                self.data.c[feature_name].is_not(None)).first()
        return first_non_null

    def _get_random_value(self, feature_name: str, no_nulls: bool = False) -> Optional[Any]:
        """
        Return a random sample from the given table column.

        The return type is determined by the column type.

        if `no_nulls` is set, select a random value from the set of non-null
        values, if any. If there are no such non-nulls, this will return None.
        """
        with session_scope(self.session_cls) as session:
            # Get the total number of non-null rows in the given table
            if no_nulls:
                num_samples = session.query(self.data.c[feature_name]).filter(
                    self.data.c[feature_name].is_not(None)).count()
            else:
                num_samples = session.query(self.data.c[feature_name]).count()

            if num_samples == 0:
                random_value = None
            else:
                # Select a pseudo-random one by offset. This should be DB
                # agnostic and reasonably performant since we only want a
                # single row/value.
                idx = np.random.randint(num_samples)
                if no_nulls:
                    random_value = (
                        session.query(self.data.c[feature_name])
                               .filter(self.data.c[feature_name].is_not(None))
                               .offset(idx)
                               .first()
                    )
                else:
                    random_value = (
                        session.query(self.data.c[feature_name])
                               .offset(idx)
                               .first()
                    )

        return random_value

    def _get_num_uniques(self, feature_name: str) -> int:
        """Return the count of unique values for the given table feature."""
        with session_scope(self.session_cls) as session:
            num_unique = (session.query(
                self.data.c[feature_name]).distinct().count())
        return num_unique

    def _has_unique_constraint(self, feature_name: str) -> bool:
        """Return True if the given feature_name has a unique constraint."""
        if self._is_primary_key(feature_name):
            # All PKs have a natural unique constraint
            return True

        # Inspect the information schema to see if the given non-pk column has
        # a unique constraint by itself.
        inspector = inspect(self.datastore.engine)
        try:
            uniques = inspector.get_unique_constraints(self.table_name.table,
                                                       schema=self.data.schema)
        except NotImplementedError:
            # MSSQL is not currently supported in sqlalchemy for get_unique_constraints;
            # however, get_indexes can also be used to identify UNIQUE constraints
            indexes = inspector.get_indexes(self.table_name.table,
                                            schema=self.data.schema)
            return any([c['column_names'] == [feature_name] and c['unique'] for c in indexes])

        return any([c['column_names'] == [feature_name] for c in uniques])

    def _get_unique_values(self, feature_name: str) -> List[Any]:
        """Get a list of all the unique values for a column."""
        with session_scope(self.session_cls) as session:
            distinct_values = (
                session.query(self.data.c[feature_name])
                       .distinct()
                       .all()
            )
        return distinct_values

    @classmethod
    def _value_to_number(cls, value: Any) -> Any:
        """Convert value to a number."""
        if pd.isna(value):
            return float('nan')
        elif isinstance(value, decimal.Decimal):
            return float(value)
        elif isinstance(value, timedelta):
            return value.total_seconds()
        elif isinstance(value, time):
            return time_to_seconds(value)
        else:
            return value

    def _get_min_max_values(self, feature_name: str) -> Tuple[Any, Any]:
        """
        Get the smallest and largest values for the given table column.

        The return type within the Tuple is determined by the column type.
        Smallness and largeness is determined by the SQLAlchemy functions
        `min()` and `max()`.
        """
        with session_scope(self.session_cls) as session:
            results = session.query(
                func.min(self.data.c[feature_name]).label("min_value"),
                func.max(self.data.c[feature_name]).label("max_value")
            ).one()

        return results.min_value, results.max_value

    def _get_mode(self, feature_name: str) -> List[Tuple[Any, int]]:
        """
        Get the most common value in the given feature/column.

        If multiple values have the same mode all of them will be returned, as
        long as the count is a value greater than 1.
        """
        with session_scope(self.session_cls) as session:
            subquery = session.query(
                func.count(self.data.c[feature_name]).label('count')
            ).group_by(self.data.c[feature_name]).subquery('all_counts')
            max_count = session.query(
                func.max(subquery.c.count).label('max_count')).scalar_subquery()
            result = (
                session.query(
                    self.data.c[feature_name],
                    func.count(self.data.c[feature_name]).label('num')
                ).group_by(
                    self.data.c[feature_name]
                ).having(
                    db.sql.and_(
                        func.count(self.data.c[feature_name]) > 1,
                        func.count(self.data.c[feature_name]) >= max_count
                    )
                )
            )

        if result is None:
            return []

        # value, count
        return [(r[0], r[1]) for r in result]

    def _get_num_features(self) -> int:
        return len(self._get_feature_names())

    def _get_num_cases(self) -> int:
        with session_scope(self.session_cls) as session:
            num_rows = session.query(self.data).count()
        return num_rows

    def _get_feature_names(self) -> List[str]:
        return [c.name for c in self.data.columns]

    def _get_feature_type(self, feature_name: str  # noqa: C901
                          ) -> Tuple[Optional[FeatureType], Optional[Dict]]:
        # Place here to avoid circular import
        from howso.client.exceptions import HowsoError
        for column in self.data.columns:
            if column.name == feature_name:
                column_type, column_options = self._parse_column_type(str(column.type))

                if column_type in self.column_types.floating_point_types:
                    typing_info = {}
                    precision = column_options.get('precision')
                    if precision and precision > 53:
                        # TODO #12919 - may need to be turned into a warning or
                        #  only raised if actual values exceed 64bit
                        raise HowsoError(
                            f'Unsupported column definition "{column.type}" '
                            f'found for column "{self.data.name}.{feature_name}", '
                            'Howso does not currently support numbers '
                            'larger than 64-bit.')

                    # Get size of float in bytes.
                    # NOTE: Some floating point numbers use variable storage
                    # sizes dependent on the dialect and the precision, we don't
                    # include the size for these
                    variable_types = self.column_types.variable_size_numbers
                    exact_types = self.column_types.exact_size_numbers
                    if column_type not in variable_types:
                        try:
                            typing_info['size'] = exact_types[column_type]
                        except (TypeError, KeyError):
                            # Not an exact size number
                            pass

                    try:
                        if column.type.python_type == decimal.Decimal:
                            typing_info['format'] = 'decimal'
                    except Exception:  # noqa: Deliberately broad
                        # Some type classes do not implement this attribute
                        pass

                    return FeatureType.NUMERIC, typing_info

                elif column_type in self.column_types.integer_types:
                    typing_info = {}

                    # Some dialects support unsigned integers, capture it
                    unsigned = getattr(column.type, 'unsigned', False)
                    if unsigned:
                        typing_info['unsigned'] = True

                    # Get size of integer in bytes
                    try:
                        typing_info['size'] = (
                            self.column_types.exact_size_numbers[column_type])
                    except (TypeError, KeyError):
                        # Not an exact size number
                        pass

                    return FeatureType.INTEGER, typing_info

                elif column_type in self.column_types.datetime_types:
                    return FeatureType.DATETIME, {}

                elif column_type in self.column_types.time_types:
                    tz_aware_types = self.column_types.tz_aware_date_time_types
                    if (
                        getattr(column.type, 'timezone', False) or
                        column_type in tz_aware_types
                    ):
                        # If timezone aware, treat as a datetime
                        return FeatureType.DATETIME, {}
                    return FeatureType.TIME, {}

                elif column_type in self.column_types.date_types:
                    return FeatureType.DATE, {}

                elif column_type in self.column_types.timedelta_types:
                    # All time deltas will be converted to seconds
                    return FeatureType.TIMEDELTA, {'unit': 'seconds'}

                elif column_type in self.column_types.boolean_types:
                    return FeatureType.BOOLEAN, {}

                elif column_type in self.column_types.string_types:
                    typing_info = {}
                    if column_options.get('length'):
                        typing_info['length'] = column_options.get('length')

                    return FeatureType.STRING, typing_info

                else:
                    return FeatureType.UNKNOWN, {}

        return None, None

    def _infer_floating_point_attributes(self, feature_name: str) -> Dict:
        if (
                self._is_primary_key(feature_name) or
                self._is_foreign_key(feature_name)
        ):
            return {
                'type': 'nominal',
                'data_type': 'number',
            }

        attributes = {"type": "continuous"}
        num_cases = self._get_num_cases()

        column = self.data.c[feature_name]
        with session_scope(self.session_cls) as session:
            num_nulls = session.query(column).filter(column.is_(None)).count()

        # Ensure we have at least one valid value before attempting to
        # introspect further.
        if num_nulls < num_cases:
            # Determine if nominal by checking if number of uniques <= 2
            if (
                    self._get_num_uniques(feature_name) <= 2 and
                    num_cases > 10
            ):
                attributes = {
                    'type': 'nominal',
                    'data_type': 'number',
                }

        column_type, _ = self._parse_column_type(str(column.type))

        # Capture scale as decimal_places (if applicable)
        if getattr(column.type, 'scale', None) is not None:
            attributes['decimal_places'] = column.type.scale

        # Capture precision as significant_digits (if applicable)
        # Note: Since core works in double precision, we can exclude the
        # significant digits for precision 53 since it won't be necessary and
        # including it will affect performance.
        precision = None
        if getattr(column.type, 'precision', None) is not None:
            precision = column.type.precision
        elif column_type in ("BINARY_FLOAT", "FLOAT", "REAL", ):
            precision = 24

        if precision and precision < 53:
            attributes['significant_digits'] = ceil(
                precision * (log(2) / log(10)))

        return attributes

    def _infer_datetime_attributes(self, feature_name: str) -> Dict:
        # Although rare, it is plausible that a datetime field could be a
        # primary- or foreign-key.
        if (
                self._is_primary_key(feature_name) or
                self._is_foreign_key(feature_name)
        ):
            return {
                "type": "nominal",
            }

        column = self.data.c[feature_name]
        column_type, _ = self._parse_column_type(str(column.type))
        if (
            getattr(column.type, 'timezone', False) or
            column_type in self.column_types.tz_aware_date_time_types
        ):
            dt_format = ISO_8601_FORMAT + "%z"
        else:
            dt_format = ISO_8601_FORMAT

        return {
            'type': 'continuous',
            'date_time_format': dt_format,
        }

    def _infer_date_attributes(self, feature_name: str) -> Dict:
        # Although rare, it is plausible that a date field could be a
        # primary- or foreign-key.
        if (
                self._is_primary_key(feature_name) or
                self._is_foreign_key(feature_name)
        ):
            return {
                "type": "nominal",
            }

        return {
            'type': 'continuous',
            'date_time_format': ISO_8601_DATE_FORMAT,
        }

    def _infer_time_attributes(self, feature_name: str) -> Dict:
        return {
            'type': 'continuous',
        }

    def _infer_timedelta_attributes(self, feature_name: str) -> Dict:
        # Although rare, it is plausible that a timedelta field could be a
        # primary- or foreign-key.
        if (
                self._is_primary_key(feature_name) or
                self._is_foreign_key(feature_name)
        ):
            return {
                'type': 'nominal',
            }

        return {
            'type': 'continuous'
        }

    def _infer_boolean_attributes(self, feature_name: str) -> Dict:
        return {
            'type': 'nominal',
            'data_type': 'boolean',
        }

    def _infer_integer_attributes(self, feature_name: str) -> Dict:
        # Most primary keys will be integer types (but not all). These are
        # always treated as nominals.
        if (
                self._is_primary_key(feature_name) or
                self._is_foreign_key(feature_name)
        ):
            return {
                'type': 'nominal',
                'data_type': 'number',
                'decimal_places': 0,
            }

        # Decide if categorical by checking number of uniques is fewer
        # than the square root of the total samples or if every value
        # has exactly the same length.
        num_uniques = self._get_num_uniques(feature_name)
        n_cases = self._get_num_cases()
        if num_uniques < pow(n_cases, 0.5):
            guess_nominals = True
        else:
            # Find the largest and smallest non-null values in column.
            try:
                col_min, col_max = (
                    self._get_min_max_values(feature_name))
                if col_min is None or col_max is None:
                    raise AssertionError('No data in the column?')
            except (AssertionError, TypeError):
                # Column is all None?
                guess_nominals = False
            else:
                # Guess nominals if ALL of:
                #   - `col_min` and `col_max` are both greater than zero
                #   - Their length is at least 5
                #   - They have the same length
                guess_nominals = (
                    col_min > 0 and col_max > 0 and
                    len(str(col_min)) >= 5 and
                    len(str(col_min)) == len(str(col_max))
                )

        if guess_nominals:
            attributes = {
                'type': 'nominal',
                'data_type': 'number',
                'decimal_places': 0,
            }
        else:
            attributes = {
                'type': 'continuous',
                'decimal_places': 0,
            }

        return attributes

    def _infer_string_attributes(self, feature_name: str) -> Dict:
        if (
                self._is_primary_key(feature_name) or
                self._is_foreign_key(feature_name)
        ):
            return {
                'type': 'nominal',
            }

        # Column has arbitrary string values, first check if they
        # are ISO8601 datetimes.
        if self._is_iso8601_datetime_column(feature_name):
            # if datetime, determine the iso8601 format it's using
            sample = self._get_first_non_null(feature_name)
            fmt = determine_iso_format(sample, feature_name)
            return {
                'type': 'continuous',
                'date_time_format': fmt
            }
        else:
            return self._infer_unknown_attributes(feature_name)

    def _infer_unknown_attributes(self, feature_name: str) -> Dict:
        return {
            'type': 'nominal'
        }

    def _infer_feature_bounds(self,  # noqa: C901
                              feature_attributes: Mapping[str, Mapping],
                              feature_name: str,
                              tight_bounds: Optional[Iterable[str]] = None,
                              mode_bound_features: Optional[List[str]] = None,
                              ) -> Optional[Dict]:
        output = None
        allow_null = True
        original_type = feature_attributes[feature_name]['original_type']

        # Only integers by default do no allow nulls.
        if original_type.get('data_type') == FeatureType.INTEGER.value:
            allow_null = False

        if feature_attributes[feature_name].get('type') == 'continuous':
            # Grab the natural feature_type and raw_feature_type
            column = self.data.c[feature_name]
            column_type, _ = self._parse_column_type(str(column.type))
            format_dt = None

            if 'date_time_format' in feature_attributes[feature_name]:
                format_dt = (
                    feature_attributes[feature_name].get('date_time_format'))

                if column_type in self.column_types.all_date_time_types:
                    # The datetime values are stored in a datetime column. We
                    # can get bounds by asking the database.
                    min_date_obj, max_date_obj = (
                        self._get_min_max_values(feature_name))

                else:
                    # For some reason, the feature was declared as date/time
                    # information but it is not in a datetime database column
                    # (E.g. "01252022"). This is unfortunate because that means
                    # the column isn't sortable in a datetime-aware manner.

                    # This loop grabs all the distinct values, then converts
                    # them according to the `format_dt` to a proper datetime
                    # instance, then compares them to find min and max values.
                    min_date_obj = datetime.max
                    max_date_obj = datetime.min

                    try:
                        unique_values = self._get_unique_values(feature_name)
                        # The comma in this loop is necessary since
                        # unique_values is a list of sqlalchemy Row values
                        for dt_str, in unique_values:
                            # Parse using the `format_dt` into a datetime
                            if dt_str:  # skip any empty values
                                date_obj = datetime.strptime(dt_str, format_dt)
                                min_date_obj = min(min_date_obj, date_obj)
                                max_date_obj = max(max_date_obj, date_obj)
                        else:
                            warnings.warn(
                                f'Cannot guess the bounds for feature '
                                f'"{feature_name}" without samples.')
                            return None
                    except Exception:  # noqa: Intentionally broad
                        warnings.warn(
                            f'Feature {feature_name} does not match the '
                            f'provided date time format, unable to guess '
                            f'bounds.')
                        return None

                # Convert the found date bounds to float seconds since Epoch
                min_value = date_to_epoch(min_date_obj, format_dt)
                max_value = date_to_epoch(max_date_obj, format_dt)

            else:
                min_value, max_value = (
                    self._get_min_max_values(feature_name))
                min_value = self._value_to_number(min_value)
                max_value = self._value_to_number(max_value)

            if (
                min_value is not None and max_value is not None and
                not isnan(min_value) and
                not isnan(max_value)
            ):
                actual_min_value = min_value
                actual_max_value = max_value
                if (
                    tight_bounds is None
                    or feature_name not in tight_bounds
                ):
                    min_value, max_value = (
                        self.infer_loose_feature_bounds(actual_min_value,
                                                        actual_max_value))

                    if (
                        mode_bound_features is None or
                        feature_name in mode_bound_features
                    ):
                        # If the mode for the feature is same as an original
                        # bound, set that appropriate bound to the mode value
                        # since in this case, it probably represents an
                        # application-specific min/max.
                        col_modes = self._get_mode(feature_name)

                        for mode_value, mode_count in col_modes:
                            if mode_count < 4:
                                # Only apply when the value has more than 3
                                # instances in the dataset
                                continue
                            if format_dt:
                                mode_f = date_to_epoch(mode_value, format_dt)
                            else:
                                mode_f = self._value_to_number(mode_value)
                            if actual_min_value == mode_f:
                                min_value = actual_min_value
                            if actual_max_value == mode_f:
                                max_value = actual_max_value

                output = {'min': min_value, 'max': max_value}
                if not allow_null:
                    output['allow_null'] = False
            else:
                # If no min/max were found from the data, use min/max size of
                # the data type.
                min_value, max_value = self._get_min_max_number_size_bounds(
                    feature_attributes, feature_name)
                if min_value is not None and max_value is not None:
                    output = {'min': min_value, 'max': max_value}

        else:
            if not allow_null:
                output = {'allow_null': False}

        return output

    def _parse_column_type(self, full_type_str: str) -> Tuple[str, dict]:
        """
        Determine column type from schema description of column.

        Given a full column type string, return a simplified type string and
        its sizes. Examples:

            "VARCHAR(255)" => 'VARCHAR', {'length': 255}
            "DECIMAL(5,2)" => 'DECIMAL', {'precision': 5, 'scale': 2}
            "FLOAT(5)"     => 'FLOAT', {'precision': 5}
            "INTEGER(16)"  => 'INTEGER', {'length': 16}

        Parameters
        ----------
        full_type_str : str
            A string describing an SQL table column type

        Returns
        -------
        tuple : str, dict
            The base type string,
            A dictionary of the relevant parts. The 'type', and depending on
            the type, additional keys 'precision', 'scale', or 'length'.
        """
        if '(' not in full_type_str:
            return full_type_str.upper(), {}

        matched = COLUMN_TYPE_PATTERN.match(full_type_str)
        type_str = matched.group('type').upper()

        if matched.group('precision') is None:
            return_dict = {}
        elif matched.group('scale') is None:
            # Handles "VARCHAR(255)" or "INTEGER(16)", etc.
            # length = int/string
            # precision = float
            numeric_types = self.column_types.floating_point_types
            key = 'precision' if type_str in numeric_types else 'length'
            return_dict = {key: int(matched.group('precision'))}
        else:
            # Handles "DECIMAL(p,s)" or "NUMERIC(p,s)", etc.
            precision = int(matched.group('precision'))
            scale = int(matched.group('scale'))
            return_dict = {'precision': precision, 'scale': scale}

        return type_str, return_dict


class InferFeatureAttributesSQLDatastore:
    """Supports inferring feature attributes for SQL datastores."""

    def __init__(self, datastore: SQLRelationalDatastoreProtocol,
                 table_names: Iterable[TableNameProtocol], **kwargs):
        """Instantiate this InferFeatureAttributesSQLDatastore object."""
        # Save kwargs for get_parameters
        self.params = kwargs

        # Set table information
        self.datastore = datastore
        self.session_cls = sessionmaker(bind=self.datastore.engine)
        self._dialect = self.datastore.engine.dialect
        self.column_types = DatastoreColumnTypes(self._dialect)
        self._metadata = MetaData()
        self.table_names = table_names

    def __call__(self, **kwargs) -> MultiTableFeatureAttributes:
        """Process feature attributes for all tables in the datastore, returned as one object."""
        # Infer feature attributes for each table in the datastore
        feature_attributes = {}

        for table_name in self.table_names:
            data = db.Table(
                table_name.table, self._metadata, schema=table_name.schema,
                autoload_with=self.datastore.engine
            )
            infer_table_attributes = InferFeatureAttributesSQLTable(data, table_name,
                                                                    self.column_types,
                                                                    self.session_cls,
                                                                    self.datastore)
            table_attributes = infer_table_attributes(**kwargs)
            feature_attributes[str(table_name)] = table_attributes

        return MultiTableFeatureAttributes(feature_attributes, kwargs)

howso/utilities/feature_attributes/relational.py
howsoai/howso-engine-py
"""Tests the `infer_feature_attributes` package."""
from collections import OrderedDict
from copy import copy
import datetime
import json
from pathlib import Path
import platform
import warnings

from howso.utilities.feature_attributes import infer_feature_attributes
from howso.utilities.feature_attributes.base import FLOAT_MAX, FLOAT_MIN, INTEGER_MAX
from howso.utilities.feature_attributes.pandas import InferFeatureAttributesDataFrame
from howso.utilities.features import FeatureType
import numpy as np
import pandas as pd
import pytest
import pytz

if platform.system().lower() == 'windows':
    DT_MAX = '6053-01-24'
    ALMOST_DT_MAX = '6053-01-23'
else:
    DT_MAX = '2262-04-11'
    ALMOST_DT_MAX = '2262-04-10'

cwd = Path(__file__).parent.parent.parent.parent
iris_path = Path(cwd, 'utilities', 'tests', 'data', 'iris.csv')
int_path = Path(cwd, 'utilities', 'tests', 'data', 'integers.csv')
stock_path = Path(cwd, 'utilities', 'tests', 'data', 'mini_stock_data.csv')
ts_path = Path(cwd, 'utilities', 'tests', 'data', 'example_timeseries.csv')

# Partially defined dictionary-1
features_1 = {
    "sepal_length": {
        "type": "continuous",
        'bounds': {
            'min': 2.72,
            'max': 3,
            'allow_null': True
        },
    },
    "sepal_width": {
        "type": "continuous"
    }
}

# Partially defined dictionary-2
features_2 = {
    "sepal_length": {
        "type": "continuous"
    },
    "sepal_width": {
        "type": "continuous"
    }
}

# Partially defined dictionary-3
features_3 = {
    "sepal_length": {
        "type": "nominal"
    },
    "sepal_width": {
        "type": "continuous"
    }
}

# Partially defined "ordered" dict
features_4 = OrderedDict(
    (f_name, features_3[f_name]) for f_name in features_3
)


def test_infer_features_attributes():
    """Litmus test for infer feature types for iris dataset."""
    df = pd.read_csv(iris_path)

    expected_types = {
        "sepal_length": "continuous",
        "sepal_width": "continuous",
        "petal_length": "continuous",
        "petal_width": "continuous",
        "class": "nominal"
    }

    features = infer_feature_attributes(df)

    for feature, attributes in features.items():
        assert expected_types[feature] == attributes['type']


@pytest.mark.parametrize(
    "features",
    [features_1, features_2, features_3, features_4]
)
def test_partially_filled_feature_types(features: dict) -> None:
    """
    Make sure the partially filled feature types remain intact.

    Parameters
    ----------
    df: pandas.DataFrame
    features
    """
    df = pd.read_csv(iris_path)
    pre_inferred_features = features.copy()

    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        inferred_features = infer_feature_attributes(df, features=features)

    for k, v in pre_inferred_features.items():
        assert v['type'] == inferred_features[k]['type']

        if 'bounds' in v:
            # Make sure the bounds are not altered
            # by _process()
            assert v['bounds'] == inferred_features[k]['bounds']


@pytest.mark.parametrize(
    'feature, nominality', [
        # "id_no" _would_ be inferred "continuous", but we specifically tell
        # `_process()` that it is indeed an ID feature, so it
        # will be set to "nominal".
        ('id_no', 'nominal'),

        # The "badge_no" feature contains ALL unique values so it exceeds the
        # sqrt(total num. rows) test, but they are all the same length
        # integers, so it passes "all the same length" check.
        ('badge_no', 'nominal'),

        # The "salary" feature has mostly uniques (some duplicates) but too
        # many that it readily exceeds the threshold of sqrt(total num. rows)
        # and they are not all the same length, so, "continuous".
        ('salary', 'continuous'),

        # The "dept_no" feature has a number of uniques that exceed the
        # sqrt(total num. rows) but all the integers are the same length,
        # so "nominal".
        ('dept_no', 'nominal'),

        # This column is all None, will be returned as "continuous".
        ('hat_size', 'continuous'),
    ]
)
def test_integer_nominality(feature, nominality):
    """Exercise infer_feature_attributes for integers and their nominality."""
    df = pd.read_csv(int_path)
    inferred_features = infer_feature_attributes(df, id_feature_name=['id_no'])
    assert inferred_features[feature]['type'] == nominality


@pytest.mark.parametrize("data, expected_type", [
    # Integer
    (pd.DataFrame([[1], [None]], dtype='Int8', columns=['a']),
     {'data_type': str(FeatureType.INTEGER), 'size': 1}),
    (pd.DataFrame([[1], [16]], dtype='int', columns=['a']),
     {'data_type': str(FeatureType.INTEGER), 'size': 8}),
    # Float
    (pd.DataFrame([[1.0], [4.4]], dtype='float', columns=['a']),
     {'data_type': str(FeatureType.NUMERIC), 'size': 8}),
    (pd.DataFrame([[None], [4.4]], dtype='float32', columns=['a']),
     {'data_type': str(FeatureType.NUMERIC), 'size': 4}),
    # Boolean
    (pd.DataFrame([[True], [False], [None]], dtype='bool', columns=['a']),
     {'data_type': str(FeatureType.BOOLEAN)}),
    # String
    (pd.DataFrame([["test"], [None]], columns=['a']),
     {'data_type': str(FeatureType.STRING)}),
    (pd.DataFrame([["test"], [None]], dtype='string', columns=['a']),
     {'data_type': str(FeatureType.STRING)}),
    (pd.DataFrame([["test"], [None]], dtype=np.string_, columns=['a']),
     {'data_type': str(FeatureType.STRING)}),
    (pd.DataFrame([["test"]], dtype='S', columns=['a']),
     {'data_type': str(FeatureType.STRING)}),
    (pd.DataFrame([["test"]], dtype='U', columns=['a']),
     {'data_type': str(FeatureType.STRING)}),
    # Datetime
    (pd.DataFrame([["2020-01-01T10:00:00"]], dtype='datetime64[ns]', columns=['a']),
     {'data_type': str(FeatureType.DATETIME)}),
    (pd.DataFrame([["2020-01-01"]], dtype='datetime64[ns]', columns=['a']),
     {'data_type': str(FeatureType.DATETIME)}),
    (pd.DataFrame([[datetime.datetime.now()]], columns=['a']),
     {'data_type': str(FeatureType.DATETIME)}),
    (pd.DataFrame([[datetime.datetime.now(pytz.timezone('US/Eastern'))]], columns=['a']),
     {'data_type': str(FeatureType.DATETIME), 'timezone': 'US/Eastern'}),
    (pd.DataFrame([[datetime.datetime.now(pytz.FixedOffset(300))]], columns=['a']),
     {'data_type': str(FeatureType.DATETIME)}),
    # Date
    (pd.DataFrame([[datetime.date(2020, 1, 1)]], columns=['a']),
     {'data_type': str(FeatureType.DATE)}),
    # Timedelta
    (pd.DataFrame([[datetime.timedelta(days=1)]], columns=['a']),
     {'data_type': str(FeatureType.TIMEDELTA), 'unit': 'seconds'}),
    (pd.DataFrame([[np.timedelta64(5, 'D')]], columns=['a']),
     {'data_type': str(FeatureType.TIMEDELTA), 'unit': 'seconds'}),
    (pd.DataFrame([[np.timedelta64(5, 'Y')]], columns=['a']),
     {'data_type': str(FeatureType.TIMEDELTA), 'unit': 'seconds'}),
    (pd.DataFrame([[np.timedelta64(5000, 'ns')]], columns=['a']),
     {'data_type': str(FeatureType.TIMEDELTA), 'unit': 'seconds'}),
    (pd.DataFrame([[np.timedelta64(5000, 's')]], columns=['a']),
     {'data_type': str(FeatureType.TIMEDELTA), 'unit': 'seconds'}),
    (pd.DataFrame([[np.timedelta64(60, 'm')]], columns=['a']),
     {'data_type': str(FeatureType.TIMEDELTA), 'unit': 'seconds'}),
])
def test_get_feature_type(data, expected_type):
    """Test get_feature_type returns expected data types."""
    infer = InferFeatureAttributesDataFrame(data)
    feature_type, original_type = infer._get_feature_type('a')
    expected_feature_type = expected_type.pop('data_type')
    assert str(feature_type) == expected_feature_type
    assert original_type == expected_type


@pytest.mark.parametrize('data, data_type', [
    (123, 'float128'),
])
def test_get_feature_type_raises(data, data_type):
    """Test get_feature_type raises exception."""
    # Place this here to avoid circular import
    from howso.client.exceptions import HowsoError
    if not hasattr(np, data_type):
        pytest.skip('Unsupported platform')

    with pytest.raises(HowsoError):
        df = pd.DataFrame([[getattr(np, data_type)(data)]], columns=['a'])
        infer_feature_attributes(df)


@pytest.mark.parametrize('should_fail, data', [
    (True, [[1]]),
    (True, {3: [1]}),
    (False, {'col1': [1]}),
])
def test_column_names(should_fail, data):
    """Test invalid column names raises."""
    df = pd.DataFrame(data)
    if should_fail:
        expected_msg = r"Unexpected DataFrame column name format"
        with pytest.raises(ValueError, match=expected_msg):
            infer_feature_attributes(df)
    else:
        features = infer_feature_attributes(df)
        assert features is not None


@pytest.mark.parametrize('should_include, base_features, dependent_features', [
    (False, None, None),
    (True, None, {'sepal_length': ['sepal_width', 'class']}),
    (True, {"sepal_length": {"type": "continuous"}},
     {'sepal_width': ['sepal_length']}),
    (True, {"sepal_length": {"type": "continuous"}},
     {'sepal_length': ['class']}),
    (False, {"sepal_length": {"type": "continuous"}},
     None),
    (True, {"sepal_length": {"dependent_features": ["class"]}},
     None),
])
def test_dependent_features(should_include, base_features, dependent_features):
    """Test depdendent features are added to feature attributes dict."""
    df = pd.read_csv(iris_path)
    features = infer_feature_attributes(df, features=base_features, dependent_features=dependent_features)

    if should_include:
        # Should include dependent features
        if dependent_features:
            for feat, dep_feats in dependent_features.items():
                assert 'dependent_features' in features[feat]
                for dep_feat in dep_feats:
                    assert dep_feat in features[feat]['dependent_features']
        # Make sure dependent features provided in the base dict are also included
        if base_features:
            for feat in base_features.keys():
                if 'dependent_features' in base_features[feat]:
                    assert 'dependent_features' in features[feat]
                    for dep_feat in base_features[feat]['dependent_features']:
                        assert dep_feat in features[feat]['dependent_features']
    else:
        # Should not include dependent features
        for attributes in features.values():
            assert 'dependent_features' not in attributes


@pytest.mark.parametrize('tight_bounds, data, expected_bounds', [
    (None, [2, 3, 4, 5, 6, 7], {'min': 1, 'max': 7.38905609893065, 'allow_null': False}),
    (None, [2, 3, 4, 4, 5, 6, 6, 6, 6], {'min': 1, 'max': 6, 'allow_null': False}),
    (None, [2, 3, 4, 4, 4, 4, 6, 6, 6, 6], {'min': 1, 'max': 6, 'allow_null': False}),
    (None, [2, 2, 2, 2, 4, 5, 6, 6, 6, 6], {'min': 2, 'max': 6, 'allow_null': False}),
    (None, [2, 2, 2, 2, 4, 5, 6, 6, 6, 6, 6], {'min': 1, 'max': 6, 'allow_null': False}),
    (None, [2, 2, 2, 2, 4, 5, 6, 7], {'min': 2, 'max': 7.38905609893065, 'allow_null': False}),
    (['a'], [2, 3, 4, 5, 6, 7], {'min': 2, 'max': 7, 'allow_null': False}),
    (['a'], [2, 3, 4, None, 6, 7], {'min': 2, 'max': 7}),
    (
        ['a'],
        ['1905-01-01', '1904-05-03', '2020-01-15', '2000-04-26', '2000-04-24'],
        {'min': '1904-05-03', 'max': '2020-01-15'}
    ),
    (
        None,
        ['1905-01-01', '1904-05-03', '2020-01-15', '2000-04-26', '2000-04-24'],
        {'min': '1856-05-25', 'max': '2083-08-08'}
    ),
    (
        None,
        ['1905-01-01', '1904-05-03', '2020-01-15', '2020-01-15', '2020-01-15',
         '2020-01-15', '2000-04-26', '2000-04-24'],
        {'min': '1856-05-25', 'max': '2020-01-15'}
    ),
    (
        None,
        ['1905-01-01', '1904-05-03', '1904-05-03', '1904-05-03', '1904-05-03',
         '2020-01-15', '2020-01-15', '2020-01-15', '2020-01-15', '2000-04-26',
         '2000-04-24'],
        {'min': '1904-05-03', 'max': '2020-01-15'}
    ),
    (
        None,
        ["1905-01-01T00:00:00+0100", "2022-03-26T00:00:00+0500",
         "1904-05-03T00:00:00+0500", "1904-05-03T00:00:00+0500",
         "1904-05-03T00:00:00+0500", "1904-05-03T00:00:00-0200",
         "1904-05-03T00:00:00+0500", "2022-01-15T00:00:00+0500"],
        {'min': '1904-05-03T00:00:00+0500', 'max': '2083-08-08T01:07:26+0500'}
    ),
    (
        ['a'],
        [datetime.datetime(1905, 1, 1), datetime.datetime(1904, 5, 3),
         datetime.datetime(2020, 1, 15), datetime.datetime(2022, 3, 26)],
        {'min': '1904-05-03T00:00:00', 'max': '2022-03-26T00:00:00'}
    ),
    (
        None,
        [datetime.datetime(1905, 1, 1), datetime.datetime(1904, 5, 3),
         datetime.datetime(2020, 1, 15), datetime.datetime(2022, 3, 26)],
        {'min': '1856-05-25T22:52:33', 'max': '2083-08-08T01:07:26'}
    ),
    (
        None,
        [datetime.datetime(1905, 1, 1), datetime.datetime(1904, 5, 3),
         datetime.datetime(1904, 5, 3), datetime.datetime(1904, 5, 3),
         datetime.datetime(1904, 5, 3), datetime.datetime(1904, 5, 3),
         datetime.datetime(2020, 1, 15), datetime.datetime(2022, 3, 26)],
        {'min': '1904-05-03T00:00:00', 'max': '2083-08-08T01:07:26'}
    ),
    (
        None,
        [datetime.datetime(1905, 1, 1, tzinfo=pytz.FixedOffset(300)),
         datetime.datetime(1904, 5, 3, tzinfo=pytz.FixedOffset(300)),
         datetime.datetime(1904, 5, 3, tzinfo=pytz.FixedOffset(300)),
         datetime.datetime(1904, 5, 3, tzinfo=pytz.FixedOffset(300)),
         datetime.datetime(1904, 5, 3, tzinfo=pytz.FixedOffset(300)),
         datetime.datetime(1904, 5, 3, tzinfo=pytz.FixedOffset(300)),
         datetime.datetime(2020, 1, 15, tzinfo=pytz.FixedOffset(300)),
         datetime.datetime(2022, 3, 26, tzinfo=pytz.FixedOffset(300))],
        {'min': '1904-05-03T00:00:00+0500', 'max': '2083-08-08T01:07:26+0500'}
    ),
    (
        None,
        [datetime.datetime(1905, 1, 1, tzinfo=pytz.FixedOffset(100)),
         datetime.datetime(1904, 5, 3, tzinfo=pytz.FixedOffset(300)),
         datetime.datetime(1904, 5, 3, tzinfo=pytz.FixedOffset(-400)),
         datetime.datetime(1904, 5, 3, tzinfo=pytz.FixedOffset(300)),
         datetime.datetime(1904, 5, 3, tzinfo=pytz.FixedOffset(300)),
         datetime.datetime(1904, 5, 3, tzinfo=pytz.FixedOffset(300)),
         datetime.datetime(2020, 1, 15, tzinfo=pytz.FixedOffset(300)),
         datetime.datetime(2022, 3, 26, tzinfo=pytz.FixedOffset(300))],
        {'min': '1904-05-03T00:00:00+0500', 'max': '2083-08-08T01:07:26+0500'}
    ),
    (
        ['a'],
        [datetime.timedelta(days=1), datetime.timedelta(days=1),
         datetime.timedelta(seconds=5), datetime.timedelta(days=1, seconds=30),
         datetime.timedelta(minutes=50), datetime.timedelta(days=5)],
        {'min': 5, 'max': 5 * 24 * 60 * 60}
    ),
    (
        None,
        [datetime.timedelta(days=1), datetime.timedelta(days=1),
         datetime.timedelta(seconds=5), datetime.timedelta(days=1, seconds=30),
         datetime.timedelta(minutes=50), datetime.timedelta(days=5),
         datetime.timedelta(days=5), datetime.timedelta(days=5),
         datetime.timedelta(days=5)],
        {'min': 2.718281828459045, 'max': 5 * 24 * 60 * 60}
    ),
    (
        None,
        [datetime.time(hour=1), datetime.time(hour=5),
         datetime.time(minute=1, second=30), datetime.time(minute=10),
         datetime.time(second=15), datetime.time(second=15),
         datetime.time(second=15), datetime.time(second=15)],
        {'min': 15, 'max': 22026.465794806718}
    ),
])
def test_infer_feature_bounds(data, tight_bounds, expected_bounds):
    """Test the infer_feature_bounds() method."""
    df = pd.DataFrame([[cell] for cell in data], columns=['a'])
    features = infer_feature_attributes(df, tight_bounds=tight_bounds)
    assert features['a']['type'] == 'continuous'
    assert 'bounds' in features['a']
    assert features['a']['bounds'] == expected_bounds


@pytest.mark.parametrize(
    "features",
    [features_1, features_2, features_3, features_4]
)
def test_to_json(features: dict) -> None:
    """Test that to_json() method returns a JSON representation of the object."""
    df = pd.read_csv(iris_path)
    pre_inferred_features = features.copy()

    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        inferred_features = infer_feature_attributes(df, features=features)

    to_json = inferred_features.to_json()
    assert isinstance(to_json, str)
    features = json.loads(to_json)

    # Make sure the json representation has expected data
    for k, v in pre_inferred_features.items():
        assert v['type'] == features[k]['type']
        if 'bounds' in v:
            assert v['bounds'] == features[k]['bounds']


@pytest.mark.parametrize('partial_features', [
    {"sepal_length": {"type": "continuous"},
     'sepal_width': {"dependent_features": ['sepal_length']}}
])
def test_get_parameters(partial_features):
    """Test the get_parameters() method."""
    df = pd.read_csv(iris_path)
    features = infer_feature_attributes(df, dropna=False, features=partial_features)

    assert features.get_parameters()['dropna'] is False

    # Verify dependent_features
    assert 'features' in features.get_parameters()
    for key, value in partial_features.items():
        assert features.get_parameters()['features'][key] == value


def test_get_names_without():
    """Test the get_names() method."""
    df = pd.read_csv(iris_path)
    features = infer_feature_attributes(df)

    all_features = list(df)

    # Test get all feature names
    assert features.get_names() == all_features

    # Test get feature names without
    without = ['sepal_length', 'petal_length', 'petal_width']
    assert features.get_names(without=without) == [f for f in all_features if f not in without]

    # Test a feature in 'without' that is not in the features list
    with pytest.raises(ValueError):
        without = ['sepal_length', 'petal_length', 'personality']
        features.get_names(without=without)


@pytest.mark.parametrize("types, num", [
    ("continuous", 4),
    ({"continuous"}, 4),
    (('nominal'), 1),
    (['continuous', 'nominal'], 5),
])
def test_get_names_types(types, num):
    """Test the get_names() method with the types parameter."""
    df = pd.read_csv(iris_path)
    features = infer_feature_attributes(df)
    names = features.get_names(types=types)
    print(names)
    assert len(names) == num


def test_copy():
    """Test that copy works as expected."""
    df = pd.read_csv(iris_path)
    f_orig = infer_feature_attributes(df)
    f_copy = copy(f_orig)

    print(f_copy.keys())
    assert f_copy.params == f_orig.params
    orig = f_orig['sepal_width']['bounds']['min']
    assert f_copy['sepal_width']['bounds']['min'] == orig

    # Now, change the orig, so we can ensure that f_copy is independent.
    f_orig['sepal_width']['bounds']['min'] = -2
    # Assert that f_copy was unaffected
    assert f_copy['sepal_width']['bounds']['min'] == orig


@pytest.mark.parametrize("features", [
    ({'OPEN': {'type': 'continuous', 'decimal_places': 3}}),
    ({'VWAP': {'decimal_places': 5}}),
    ({'DATE': {'type': 'continuous', 'date_time_format': '%Y-%m-%f'}}),
    ({'DATE': {'date_time_format': '%Y-%m-%f'}}),
    ({'CLOSE': {'type': 'continuous', 'dependent_features': ['OPEN', 'DATE']}}),
])
def test_partial_features(features):
    """Test that filling a partial features dict works as expected."""
    df = pd.read_csv(stock_path)
    feature_attributes = infer_feature_attributes(df, time_feature_name='DATE', features=features)

    # Ensure that the partial features remain
    for feature in features.keys():
        for k, v in features[feature].items():
            assert k in feature_attributes[feature]
            assert feature_attributes[feature][k] == v


@pytest.mark.parametrize("tight_bounds", [
    (['DATE', 'TURNOVER', '%DELIVERABLE']),
    (['DATE', '%DELIVERABLE']),
    (['DATE', 'TURNOVER']),
    (['%DELIVERABLE', 'TURNOVER']),
    (['DATE']),
    (['TURNOVER']),
    (['%DELIVERABLE']),
    ([''])
])
def test_tight_bounds(tight_bounds):
    """Test the tight_bounds argument with a features list."""
    df = pd.read_csv(stock_path)
    features = infer_feature_attributes(df, tight_bounds=tight_bounds)

    all_tight_bounds = infer_feature_attributes(df, tight_bounds=features.get_names())
    no_tight_bounds = infer_feature_attributes(df)

    for feature in features.keys():
        if 'bounds' not in features[feature]:
            continue
        if feature in tight_bounds:
            assert features[feature]['bounds'] == all_tight_bounds[feature]['bounds']
        else:
            assert features[feature]['bounds'] == no_tight_bounds[feature]['bounds']


def test_validate_dataframe():
    """Test the validate method with a DataFrame."""
    # Test valid feature attributes against their original datasets
    # (should not raise any exceptions!)
    # Iris dataset
    df = pd.read_csv(iris_path)
    features = infer_feature_attributes(df)
    assert features.validate(df, raise_errors=True) is None
    # Integers dataset
    df = pd.read_csv(int_path)
    features = infer_feature_attributes(df)
    assert features.validate(df, raise_errors=True) is None
    # Example timeseries dataset
    df = pd.read_csv(ts_path)
    features = infer_feature_attributes(df, time_feature_name='date')
    assert features.validate(df, raise_errors=True) is None
    # Mini stock data dataset
    df = pd.read_csv(stock_path)
    features = infer_feature_attributes(df, time_feature_name='DATE')
    assert features.validate(df, raise_errors=True) is None
    # Also try this one with a non-ts infer
    df = pd.read_csv(stock_path)
    features = infer_feature_attributes(df)
    assert features.validate(df, raise_errors=True) is None
    # Should not raise any exceptions and return a "coerced" dataframe
    df = pd.read_csv(iris_path)
    features = infer_feature_attributes(df)
    df['sepal_length'] = df['sepal_length'].astype('int64')
    df = features.validate(df, coerce=True, raise_errors=True)
    assert df is not None
    assert pd.api.types.is_float_dtype(df['sepal_length'])
    # Try validating a categorical feature
    df = pd.read_csv(iris_path)
    features['class']['type'] = 'ordinal'
    features['class']['bounds'] = {}
    unique = list(df['class'].unique())
    features['class']['bounds']['allowed'] = unique
    df['class'] = df['class'].astype(pd.CategoricalDtype(categories=unique))
    df = features.validate(df, coerce=True, raise_errors=True)
    assert df is not None
    assert pd.api.types.is_categorical_dtype(df['class'])


@pytest.mark.parametrize("ftype, data_type, decimal_places, bounds, date_time_format, expected_dtype", [
    ("continuous", "number", 0, {'allow_null': False}, None, "int64"),
    ("continuous", "number", 1, {'allow_null': False}, None, "float64"),
    ("continuous", "number", 0, {'allow_null': False}, "%Y-%m-%d", "datetime64"),
    ("ordinal", "number", 0, {'allow_null': False}, None, "int64"),
    ("ordinal", "number", 2, {'allow_null': True}, None, "float64"),
    ("ordinal", "string", None, {'allowed': ['SBIN'], 'allow_null': False}, None, "object"),
    ("nominal", "number", 0, {'allow_null': False}, None, "int64"),
    ("nominal", "number", 9, {'allow_null': False}, None, "float64"),
    ("nominal", "boolean", 0, {'allow_null': False}, None, "bool"),
])
def test_validate_df_multiple_dtypes(ftype, data_type, decimal_places, bounds, date_time_format,
                                     expected_dtype):
    """Test the validate() method with all possible inferred dtypes."""
    # First, read in the mini_stock_series dataset as it has a variety of data types
    df = pd.read_csv(stock_path)
    # Based on the expected_dtype, choose the feature in the dataset that is loosely described by the given parameters
    if expected_dtype == 'int64':
        feature = 'VOLUME'
    elif expected_dtype == 'float64':
        feature = 'PREV CLOSE'
    elif expected_dtype == 'datetime64':
        feature = 'DATE'
    elif expected_dtype == 'bool':
        # Make a new column of a bool dtype since there are none in the dataset
        df['NEW'] = True
        feature = 'NEW'
    else:
        feature = 'SYMBOL'
    # Infer the feature attributes like normal, but replace the attributes for the chosen feature
    # with our parameter attributes, which should also be considered valid.
    attrs = infer_feature_attributes(df, time_feature_name='DATE')
    attrs[feature] = {
        'type': ftype,
        'data_type': data_type,
        'decimal_places': decimal_places,
        'bounds': bounds,
        'date_time_format': date_time_format,
    }
    if not date_time_format:
        del attrs[feature]['date_time_format']
    # validate() should not raise any errors
    coerced_df = attrs.validate(df, raise_errors=True, coerce=True)
    assert coerced_df is not None
    # coerced_df should also contain a coerced DATE column, as it is originally detected as a string
    assert pd.api.types.is_datetime64_any_dtype(coerced_df['DATE'].dtype)


@pytest.mark.parametrize("extra_attrs, success", (
    ({}, False),
    ({'auto_derive_on_train': False}, False),
    ({'auto_derive_on_train': True}, False),
    ({'derived_feature_code': '{* #VOLUMNE 2.2}'}, False),
    ({'auto_derive_on_train': False,
      'derived_feature_code': '{* #VOLUMNE 2.2}'}, False),
    ({'auto_derive_on_train': True,
      'derived_feature_code': '{* #VOLUMNE 2.2}'}, True),
))
def test_validate_df_missing_features(extra_attrs, success):
    """
    Test that missing features raise warnings in `_validate_df`.

    Specifically, if a feature is to be derived during train, it should be
    exempt from raising warnings that the feature is missing.
    """
    df = pd.read_csv(stock_path)
    attrs = infer_feature_attributes(df, time_feature_name='DATE')
    # Add a would-be derived/computed feature
    attrs['to_be_computed'] = {"type": "continuous"}
    attrs['to_be_computed'].update(extra_attrs)

    if success:
        # We expect this to run without raising an error (due to the warning)
        with warnings.catch_warnings():
            warnings.simplefilter("error")
            attrs.validate(df)
    else:
        # We expect this to raise an exception when run.
        with pytest.raises(Exception):
            with warnings.catch_warnings():
                warnings.simplefilter("error")
                attrs.validate(df)


@pytest.mark.parametrize("datetime_min_max, float_min_max, int_min_max", [
    (
        ('DATE', None, None, False),
        ('CLOSE', None, None, False),
        ('VOLUME', None, None, False),
    ),
    (
        ('DATE', None, DT_MAX, True),
        ('CLOSE', FLOAT_MIN - .00001, FLOAT_MAX + .00001, True),
        ('VOLUME', int(INTEGER_MAX / 10) * -1, INTEGER_MAX, True),
    ),
    (
        ('DATE', ALMOST_DT_MAX, None, False),
        ('CLOSE', -1 * (FLOAT_MAX / 10.0), None, False),
        ('VOLUME', -1 * int(INTEGER_MAX / 10), None, False),
    ),
    (
        ('DATE', DT_MAX, ALMOST_DT_MAX, True),
        ('CLOSE', FLOAT_MIN * -0.1, FLOAT_MIN * -0.01, True),
        ('VOLUME', INTEGER_MAX * -1, (INTEGER_MAX * -1) - 1, True),
    ),
    (
        ('DATE', None, None, False),
        ('CLOSE', FLOAT_MAX * -1.0, (FLOAT_MAX * -1.0) - 1, True),
        ('VOLUME', None, None, False),
    ),
])
def test_unsupported_data(datetime_min_max, float_min_max, int_min_max):
    """Test that infer_feature_attributes correctly identifies features that contain unsupported data."""
    df = pd.read_csv(stock_path)

    expected_unsupported = {}

    for feature, val_1, val_2, unsupported in [datetime_min_max, float_min_max, int_min_max]:
        if val_1 is not None:
            df.at[0, feature] = val_1
        if val_2 is not None:
            df.at[1, feature] = val_2
        expected_unsupported[feature] = unsupported

    features = infer_feature_attributes(df, tight_bounds=True)

    for feature in features.keys():
        if expected_unsupported.get(feature, False):
            assert features.has_unsupported_data(feature)
        else:
            assert not features.has_unsupported_data(feature)


@pytest.mark.parametrize("value, is_json, is_yaml", [
    ('{"key": "value", "_key": "_value"}', True, False),
    ('{"key":\n    {"key2": [1, 2, 3, 4]}\n}', True, False),
    ('[]', True, False),
    ('{}', True, False),
    ('a: 1\nb:\nc: 3\n\nd: 4', False, True),
    ("---\nname: The Howso Engine.\ndescription: >\n  The Howso Engine™ is a "
     "natively and fully explainable ML engine and toolbox.", False, True),
    ('not:valid:\nyaml\norjson', False, False),
    (12345, False, False),
    ('abcdefg', False, False),
    ('abcd\nefg', False, False),
    (None, False, False),
])
def test_json_yaml_features(value, is_json, is_yaml):
    """Test that infer_feature_attributes correctly identifies JSON and YAML features."""
    df = pd.read_csv(iris_path)
    df['sepal_width'] = value

    features = infer_feature_attributes(df)

    if is_json:
        assert features['sepal_width']['type'] == 'continuous'
        assert features['sepal_width']['data_type'] == 'json'
    elif is_yaml:
        assert features['sepal_width']['type'] == 'continuous'
        assert features['sepal_width']['data_type'] == 'yaml'
    else:
        assert features['sepal_width'].get('data_type') != 'json'
        assert features['sepal_width'].get('data_type') != 'yaml'

howso/utilities/feature_attributes/tests/test_infer_feature_attributes.py
howsoai/howso-engine-py
"""Tests infer_feature_attributes with time series data (InferFeatureAttributesTimeSeries)."""
from collections import OrderedDict
from pathlib import Path
import warnings

from howso import client
from howso.utilities.feature_attributes import infer_feature_attributes
import pandas as pd
import pytest


data_path = (
    Path(client.__file__).parent.parent
).joinpath("utilities/tests/data/example_timeseries.csv")

# Partially defined dictionary-1
features_1 = {
    'f1': {
        'bounds': {
            'allow_null': False,
            'max': 5103.08,
            'min': 20.08
        },
        'dropna': True,
        'type': 'continuous'
    },
    'f2': {
        'bounds': {
            'allow_null': False,
            'max': 6103,
            'min': 0
        },
        'dropna': True,
        'time_series': True,
        'type': 'continuous'
    },
}


# Partially defined "ordered" dict
features_2 = OrderedDict(
    (f_name, features_1[f_name]) for f_name in features_1
)


def test_infer_features_attributes_single_ID():
    """Litmus test for infer feature types for iris dataset."""
    df = pd.read_csv(data_path)

    # Define time format
    time_format = "%Y%m%d"

    # Identify id-feature and time-feature
    id_feature_name = "ID"
    time_feature_name = "date"

    expected_types = {
        "ID": "nominal",
        "f1": "continuous",
        "f2": "continuous",
        "f3": "continuous",
        "date": "continuous"
    }
    features = infer_feature_attributes(
        df,
        time_feature_name=time_feature_name,
        id_feature_name=id_feature_name,
        datetime_feature_formats={time_feature_name: time_format}
    )

    for feature, attributes in features.items():
        print(feature)
        assert expected_types[feature] == attributes['type']


def test_infer_features_attributes_multiple_ID():
    """Litmus test for infer feature types for iris dataset with multiple ID features."""
    df = pd.read_csv(data_path)
    df["ID2"] = df["ID"].copy()

    # Define time format
    time_format = "%Y%m%d"

    # Identify id-feature and time-feature
    id_feature_name = ["ID", "ID2"]
    time_feature_name = "date"

    expected_types = {
        "ID": "nominal",
        "ID2": "nominal",
        "f1": "continuous",
        "f2": "continuous",
        "f3": "continuous",
        "date": "continuous"
    }
    features = infer_feature_attributes(
        df,
        time_feature_name=time_feature_name,
        id_feature_name=id_feature_name,
        datetime_feature_formats={time_feature_name: time_format}
    )

    for feature, attributes in features.items():
        print(feature)
        assert expected_types[feature] == attributes['type']

    assert features["ID"]["id_feature"] is True
    assert features["ID2"]["id_feature"] is True


@pytest.mark.parametrize(
    "features",
    [features_1, features_2]
)
def test_partially_filled_feature_types(features: dict) -> None:
    """
    Make sure the partially filled feature types remain intact.

    Parameters
    ----------
    df: pandas.DataFrame
    features
    """
    df = pd.read_csv(data_path)
    pre_inferred_features = features.copy()

    # Define time format
    time_format = "%Y%m%d"

    # Identify id-feature and time-feature
    id_feature_name = "ID"
    time_feature_name = "date"

    inferred_features = infer_feature_attributes(
        df,
        time_feature_name=time_feature_name,
        features=features,
        id_feature_name=id_feature_name,
        datetime_feature_formats={time_feature_name: time_format}
    )

    for k, v in pre_inferred_features.items():
        assert v['type'] == inferred_features[k]['type']

        if 'bounds' in v:
            # Make sure the bounds are not altered
            # by `infer_feature_attributes` function
            assert v['bounds'] == inferred_features[k]['bounds']


def test_set_rate_delta_boundaries():
    """Test infer_feature_attributes for time series with rate/delta boundaries set."""
    df = pd.read_csv(data_path)

    # Define time format
    time_format = "%Y%m%d"

    # Identify id-feature and time-feature
    id_feature_name = "ID"
    time_feature_name = "date"

    # Test overwrite order 0
    rate_boundaries = {"f3": {"min": {'0': 1234.5678, '1': 12345}, "max": {'0': 5678.1234}}}
    delta_boundaries = {"date": {"min": {'0': 8765.4321}, "max": {'0': 4321.8765, '1': 54321}}}

    features = infer_feature_attributes(
        df,
        time_feature_name=time_feature_name,
        id_feature_name=id_feature_name,
        datetime_feature_formats={time_feature_name: time_format},
        rate_boundaries=rate_boundaries,
        delta_boundaries=delta_boundaries,
    )

    # Make sure that order 0 was overwritten for rate/delta min & max
    assert features['f3']['time_series']['rate_min'][0] == rate_boundaries['f3']['min']['0']
    assert features['f3']['time_series']['rate_max'][0] == rate_boundaries['f3']['max']['0']
    assert features['date']['time_series']['delta_min'][0] == delta_boundaries['date']['min']['0']
    assert features['date']['time_series']['delta_max'][0] == delta_boundaries['date']['max']['0']

    # Make sure that order 1 was ignored
    assert len(features['f3']['time_series']['rate_min']) == 1
    assert len(features['f3']['time_series']['rate_max']) == 1
    assert len(features['date']['time_series']['delta_min']) == 1
    assert len(features['date']['time_series']['delta_max']) == 1

    # If a boundary type is mismatched, a warning should be raised and the value should be ignored
    with warnings.catch_warnings():
        warnings.simplefilter('ignore')
        rate_boundaries = {"date": {"min": {'0': 1234.5678, '1': 12345}, "max": {'0': 5678.1234}}}
        delta_boundaries = {"f3": {"min": {'0': 8765.4321}, "max": {'0': 4321.8765, '1': 54321}}}
        features = infer_feature_attributes(
            df,
            time_feature_name=time_feature_name,
            id_feature_name=id_feature_name,
            datetime_feature_formats={time_feature_name: time_format},
            rate_boundaries=rate_boundaries,
            delta_boundaries=delta_boundaries,
        )
        assert 'rate_min' not in features['date']['time_series']
        assert 'rate_max' not in features['date']['time_series']
        assert 'delta_min' not in features['f3']['time_series']
        assert 'delta_max' not in features['f3']['time_series']

howso/utilities/feature_attributes/tests/test_infer_time_series_attributes.py
howsoai/howso-engine-py
import logging
from math import e, isnan
from typing import (
    Dict, Iterable, Optional, Union
)
import warnings

import numpy as np
import pandas as pd
from pandas.core.dtypes.common import is_string_dtype

from .base import SingleTableFeatureAttributes
from .pandas import InferFeatureAttributesDataFrame
from ..utilities import date_to_epoch

logger = logging.getLogger(__name__)

SMALLEST_TIME_DELTA = 0.001


class InferFeatureAttributesTimeSeries:
    """Infer feature attributes for time series data."""

    def __init__(self, data: pd.DataFrame, time_feature_name: str):
        """Instantiate this InferFeatureAttributesTimeSeries object."""
        self.data = data
        self.time_feature_name = time_feature_name
        # Keep track of features that contain unsupported data
        self.unsupported = []

    def _infer_delta_min_and_max(  # noqa: C901
        self,
        features: Optional[Dict] = None,
        datetime_feature_formats: Optional[Dict] = None,
        id_feature_name: Optional[Union[str, Iterable[str]]] = None,
        orders_of_derivatives: Optional[Dict] = None,
        derived_orders: Optional[Dict] = None,
    ) -> Dict:
        """
        Infer continuous feature delta_min, delta_max for each feature.

        Parameters
        ----------
        features : dict, default None
            (Optional) A partially filled features dict. If partially filled
            attributes for a feature are passed in, those parameters will be
            retained as is and the delta_min and delta_max attributes will be
            inferred.

        datetime_feature_formats : dict, default None
            (Optional) Dict defining a custom (non-ISO8601) datetime format and
            an optional locale for features with datetimes.  By default
            datetime features are assumed to be in ISO8601 format.  Non-English
            datetimes must have locales specified.  If locale is omitted, the
            default system locale is used. The keys are the feature name, and
            the values are a tuple of date time format and locale string:

            Examples::

                {
                    "start_date" : ("%Y-%m-%d %A %H.%M.%S", "es_ES"),
                    "end_date" : "%Y-%m-%d"
                }

        id_feature_name : str or list of str, default None
            (Optional) The name(s) of the ID feature(s).

        orders_of_derivatives : dict, default None
            (Optional) Dict of features and their corresponding order of
            derivatives for the specified type (delta/rate). If provided will
            generate the specified number of derivatives and boundary values. If
            set to 0, will not generate any delta/rate features. By default all
            continuous features have an order value of 1.

        derived_orders : dict, default None
            (Optional) Dict of features to the number of orders of derivatives
            that should be derived instead of synthesized. For example, for a
            feature with a 3rd order of derivative, setting its derived_orders
            to 2 will synthesize the 3rd order derivative value, and then use
            that synthed value to derive the 2nd and 1st order.

        Returns
        -------
        features : dict
            Returns dictionary of {`type`: "feature type"}} with column names
            in passed in df as key.
        """
        # iterate over all features, ensuring that the time feature is the first
        # one to be processed so that its deltas are cached
        feature_names = set(features.keys())
        feature_names.remove(self.time_feature_name)
        feature_names = [self.time_feature_name] + list(feature_names)

        if orders_of_derivatives is None:
            orders_of_derivatives = dict()

        if derived_orders is None:
            derived_orders = dict()

        time_feature_deltas = None
        for f_name in feature_names:
            if features[f_name]['type'] == "continuous" and \
                    'time_series' in features[f_name]:
                # Set delta_max for all continuous features to the observed maximum
                # difference between two values times e.
                if isinstance(datetime_feature_formats, dict):
                    dt_format = datetime_feature_formats.get(f_name)
                else:
                    dt_format = features[f_name].get('date_time_format')

                # number of derivation orders, default to 1
                num_orders = orders_of_derivatives.get(f_name, 1)
                if num_orders > 1:
                    features[f_name]['time_series']['order'] = num_orders

                num_derived_orders = derived_orders.get(f_name, 0)
                if num_derived_orders >= num_orders:
                    old_derived_orders = num_derived_orders
                    num_derived_orders = num_orders - 1
                    warnings.warn(
                        f"Overwriting the `derived_orders` value of {old_derived_orders} "
                        f"for {f_name} with {num_derived_orders} because it must "
                        f"be smaller than the 'orders' value of {num_orders}.")
                if num_derived_orders > 0:
                    features[f_name]['time_series']['derived_orders'] = num_derived_orders

                if dt_format is not None:
                    # copy just the id columns and the time feature
                    if isinstance(id_feature_name, str):
                        df_c = self.data.loc[:, [id_feature_name, f_name]]
                    elif isinstance(id_feature_name, list):
                        df_c = self.data.loc[:, id_feature_name + [f_name]]
                    else:
                        df_c = self.data.loc[:, [f_name]]

                    # convert time feature to epoch
                    df_c[f_name] = df_c[f_name].apply(
                        lambda x: date_to_epoch(x, dt_format))

                    # use Pandas' diff() to pull all the deltas for this feature
                    if isinstance(id_feature_name, list):
                        deltas = df_c.groupby(id_feature_name)[f_name].diff(1)
                    elif isinstance(id_feature_name, str):
                        deltas = df_c.groupby([id_feature_name])[f_name].diff(1)
                    else:
                        deltas = df_c[f_name].diff(1)

                else:
                    # Use pandas' diff() to pull all the deltas for this feature
                    if isinstance(id_feature_name, list):
                        deltas = self.data.groupby(id_feature_name)[f_name].diff(1)
                    elif isinstance(id_feature_name, str):
                        deltas = self.data.groupby([id_feature_name])[f_name].diff(1)
                    else:
                        deltas = self.data[f_name].diff(1)

                if f_name == self.time_feature_name:
                    time_feature_deltas = deltas

                # initial rates are same as deltas which will then be used as input
                # to compute actual first order rates
                rates = deltas

                for order in range(1, num_orders + 1):

                    # compute rate min and max for all rate features
                    if features[f_name]['time_series']['type'] == "rate":

                        if 'rate_max' not in features[f_name]['time_series']:
                            features[f_name]['time_series']['rate_max'] = []
                        if 'rate_min' not in features[f_name]['time_series']:
                            features[f_name]['time_series']['rate_min'] = []

                        # compute the deltas between previous rates as inputs
                        # for higher order rate computations
                        if order > 1:
                            rates = np.diff(np.array(rates))

                        # compute each 1st order rate as: delta x / delta time
                        # higher order rates as: delta previous rate / delta time
                        rates = [
                            dx / (dt if dt != 0 else SMALLEST_TIME_DELTA)
                            for dx, dt in zip(rates, time_feature_deltas)
                        ]

                        # remove NaNs
                        no_nan_rates = [x for x in rates if isnan(x) is False]

                        # TODO: 15550: support user-specified min/max values
                        rate_max = max(no_nan_rates)
                        rate_max = rate_max * e if rate_max > 0 else rate_max / e
                        features[f_name]['time_series']['rate_max'].append(rate_max)

                        rate_min = min(no_nan_rates)
                        rate_min = rate_min / e if rate_min > 0 else rate_min * e
                        features[f_name]['time_series']['rate_min'].append(rate_min)
                    else:  # 'type' == "delta"

                        if 'delta_max' not in features[f_name]['time_series']:
                            features[f_name]['time_series']['delta_max'] = []
                        if 'delta_min' not in features[f_name]['time_series']:
                            features[f_name]['time_series']['delta_min'] = []

                        # compute new deltas between previous deltas as inputs
                        # for higher order delta computations
                        if order > 1:
                            deltas = deltas.diff(1)

                        delta_max = max(deltas.dropna())
                        delta_max = delta_max * e if delta_max > 0 else delta_max / e
                        features[f_name]['time_series']['delta_max'].append(delta_max)

                        delta_min = min(deltas.dropna())
                        # don't allow the time series time feature to go back in time
                        # TODO: 15550: support user-specified min/max values
                        if f_name == self.time_feature_name:
                            features[f_name]['time_series']['delta_min'].append(max(0, delta_min / e))
                        else:
                            delta_min = delta_min / e if delta_min > 0 else delta_min * e
                            features[f_name]['time_series']['delta_min'].append(delta_min)

        return features

    def _set_rate_delta_bounds(self, btype: str, bounds: Dict, features: Dict):
        """Set optinally-specified rate/delta bounds in the features dict."""
        for feature in bounds.keys():
            # Check for any problems
            if feature not in features.keys():
                raise ValueError(f"Unknown feature '{feature}' in {btype}_boundaries")
            elif features[feature]['time_series']['type'] != btype:
                warnings.warn(f"Ignoring {btype}_boundaries: feature type is not '{btype}'")
                continue
            # Set specified values
            if 'min' in bounds[feature] and f'{btype}_min' in features[feature]['time_series']:
                num_orders = len(features[feature]['time_series'][f'{btype}_min'])
                for order in bounds[feature]['min'].keys():
                    # Only adjust in-range values; ignore any others
                    if int(order) < num_orders:
                        features[feature]['time_series'][f'{btype}_min'][int(order)] = bounds[feature]['min'][order]
                    else:
                        warnings.warn(f"Ignoring {btype}_boundaries for order {order}: out of range")
            if 'max' in bounds[feature] and f'{btype}_max' in features[feature]['time_series']:
                num_orders = len(features[feature]['time_series'][f'{btype}_max'])
                for order in bounds[feature]['max'].keys():
                    if int(order) < num_orders:
                        features[feature]['time_series'][f'{btype}_max'][int(order)] = bounds[feature]['max'][order]
                    else:
                        warnings.warn(f"Ignoring {btype}_boundaries for order {order}: out of range")

    def _process(  # noqa: C901
        self,
        features: Optional[Dict] = None,
        infer_bounds: bool = True, dropna: bool = False,
        id_feature_name: Optional[Union[str, Iterable[str]]] = None,
        time_invariant_features: Optional[Iterable[str]] = None,
        datetime_feature_formats: Optional[Dict] = None,
        tight_bounds: Optional[Iterable[str]] = None,
        tight_time_bounds: bool = False,
        attempt_infer_extended_nominals: bool = False,
        nominal_substitution_config: Optional[Dict[str, Dict]] = None,
        include_extended_nominal_probabilities: Optional[bool] = False,
        time_series_type_default: Optional[str] = 'rate',
        time_series_types_override: Optional[Dict] = None,
        orders_of_derivatives: Optional[Dict] = None,
        derived_orders: Optional[Dict] = None,
        mode_bound_features: Optional[Iterable[str]] = None,
        lags: Optional[Union[list, dict]] = None,
        num_lags: Optional[Union[int, dict]] = None,
        rate_boundaries: Optional[Dict] = None,
        delta_boundaries: Optional[Dict] = None,
    ) -> Dict:
        """
        Infer time series attributes.

        Infers the feature types for each of the features in the dataframe.
        If the feature is of float, it is assumed that the feature is of type
        continuous. And any other type will be assumed to be nominal.

        Parameters
        ----------
        features : dict or None, defaults to None
            (Optional) A partially filled features dict. If partially filled
            attributes for a feature are passed in, those parameters will be
            retained as is and the rest of the attributes will be inferred.

            For example:
                >>> from pprint import pprint
                >>> df.head(2)
                ...       ID      f1      f2      f3      date
                ... 0  31855  245.47  262.97  244.95  20100131
                ... 1  31855  253.03  254.79  238.61  20100228
                >>> # Partially filled features dict
                ... partial_features = {
                ...     'f1': {
                ...         'bounds': {
                ...             'allow_null': False,
                ...             'max': 5103.08,
                ...             'min': 20.08
                ...         },
                ...         'dropna': True,
                ...         'time_series':  { 'type': 'rate'},
                ...         'type': 'continuous'
                ...     },
                ...     'f2': {
                ...         'bounds': {
                ...             'allow_null': False,
                ...             'max': 6103,
                ...             'min': 0
                ...         },
                ...         'dropna': True,
                ...         'time_series':  { 'type': 'rate'},
                ...         'type': 'continuous'
                ...     },
                ... }
                >>> # infer rest of the attributes
                >>> features = infer_time_series_attributes(
                ... df, features=partial_features, time_feature_name='date'
                ... )
                >>> # inferred Feature dictionary
                >>> pprint(features)
                ... {
                ...     'ID': {
                ...         'bounds': {'allow_null': True},
                ...         'dropna': True,
                ...         'id_feature': True,
                ...         'type': 'nominal'
                ...     },
                ...     'f1': {
                ...         'bounds': {'allow_null': False, 'max': 5103.08, 'min': 20.08},
                ...          'dropna': True,
                ...          'time_series':  { 'type': 'rate'},
                ...          'type': 'continuous'
                ...     },
                ...     'f2': {
                ...         'bounds': {'allow_null': False, 'max': 6103, 'min': 0},
                ...         'dropna': True,
                ...         'time_series':  { 'type': 'rate'},
                ...         'type': 'continuous'
                ...     },
                ...     'f3': {
                ...         'bounds': {
                ...             'allow_null': False,
                ...             'max': 8103.083927575384,
                ...             'min': 20.085536923187668},
                ...             'dropna': True,
                ...             'time_series':  { 'type': 'rate'},
                ...             'type': 'continuous'
                ...         },
                ...         'date': {
                ...             'bounds': {
                ...                 'allow_null': False,
                ...                 'max': '20830808',
                ...                 'min': '19850517'
                ...         },
                ...         'date_time_format': '%Y%m%d',
                ...         'dropna': True,
                ...         'tight_time_bounds': False,
                ...         'time_feature': True,
                ...         'time_series':  { 'type': 'delta'},
                ...         'type': 'continuous'
                ...     }
                ... }

        infer_bounds : bool, default True
            (Optional) If True, bounds will be inferred for the features if the
            feature column has at least one non NaN value

        delta_boundaries : dict, default None
            (Optional) For time series, specify the delta boundaries in the form
            {"feature" : {"min|max" : {order : value}}}. Works with partial values
            by specifying only particular order of derivatives you would like to
            overwrite. Invalid orders will be ignored.

            Examples::

                {
                    "stock_value": {
                        "min": {
                            '0' : 0.178,
                            '1': 3.4582e-3,
                            '2': None
                        }
                    }
                }

        dropna : bool, default False
            (Optional) If True, all features will be populated with `'dropna':
            True` parameter. That would mean, rows containing NaNs will be
            automatically dropped when you train.

        id_feature_name : str or list of str default None
            (Optional) The name(s) of the ID feature(s).

        time_invariant_features : list of str, default None
            (Optional) Names of time-invariant features.

        datetime_feature_formats : dict, default None
            (Optional) Dict defining a custom (non-ISO8601) datetime format and
            an optional locale for features with datetimes.  By default datetime
            features are assumed to be in ISO8601 format.  Non-English datetimes
            must have locales specified.  If locale is omitted, the default
            system locale is used. The keys are the feature name, and the values
            are a tuple of date time format and locale string:

            Examples::

                {
                    "start_date" : ("%Y-%m-%d %A %H.%M.%S", "es_ES"),
                    "end_date" : "%Y-%m-%d"
                }

        tight_bounds: Iterable of str, default None
            (Optional) Set tight min and max bounds for the features
            specified in the Iterable.

        tight_time_bounds : bool, default False
            (optional) If True, will set tight bounds for time_feature.
            This will cause the bounds for the start and end times set
            to the same bounds as observed in the original data.

        attempt_infer_extended_nominals : bool, default False
            (Optional) If set to True, detections of extended nominals will be
            attempted. If the detection fails, the categorical variables will
            be set to `int-id` subtype.

            .. note ::
                Please refer to `kwargs` for other parameters related to
                extended nominals.

        nominal_substitution_config : dict of dicts, default None
            (Optional) Configuration of the nominal substitution engine
            and the nominal generators and detectors.

        include_extended_nominal_probabilities : bool, default False
            (Optional) If true, extended nominal probabilities will be appended
            as metadata into the feature object.

        time_series_type_default : str, default 'rate'
            (Optional) Type specifying how time series is generated.
            One of 'rate' or 'delta', default is 'rate'. If 'rate',
            it uses the difference of the current value from its
            previous value divided by the change in time since the
            previous value. When 'delta' is specified, just uses
            the difference of the current value from its previous value
            regardless of the elapsed time.

        time_series_types_override : dict, default None
            (Optional) Dict of features and their corresponding time series type,
            one of 'rate' or 'delta', used to override time_series_type_default
            for the specified features.

        orders_of_derivatives : dict, default None
            (Optional) Dict of features and their corresponding order of
            derivatives for the specified type (delta/rate). If provided will
            generate the specified number of derivatives and boundary values. If
            set to 0, will not generate any delta/rate features. By default all
            continuous features have an order value of 1.

        derived_orders : dict, default None
            (Optional) Dict of features to the number of orders of derivatives
            that should be derived instead of synthesized. For example, for a
            feature with a 3rd order of derivative, setting its derived_orders
            to 2 will synthesize the 3rd order derivative value, and then use
            that synthed value to derive the 2nd and 1st order.

        mode_bound_features : list of str, default None
            (Optional) Explicit list of feature names to use mode bounds for
            when infering loose bounds. If None, assumes all features except the
            time feature. A mode bound is used instead of a loose bound when the
            mode for the feature is the same as an original bound, as it may
            represent an application-specific min/max.

        lags : list or dict, default None
            (Optional) A list containing the specific indices of the desired lag
            features to derive for each feature (not including the series time
            feature). Specifying derived lag features for the feature specified by
            time_feature_name must be done using a dictionary. A dictionary can be
            used to specify a list of specific lag  indices for specific features.
            For example: {"feature1": [1, 3, 5]} would derive three different
            lag features for feature1. The resulting lag features hold values
            1, 3, and 5 timesteps behind the current timestep respectively.

            .. note ::
                Using the lags parameter will override the num_lags parameter per
                feature

            .. note ::
                A lag feature is a feature that provides a "lagging value" to a
                case by holding the value of a feature from a previous timestep.
                These lag features allow for cases to hold more temporal information.

        num_lags : int or dict, default None
            (Optional) An integer specifying the number of lag features to
            derive for each feature (not including the series time feature).
            Specifying derived lag features for the feature specified by
            time_feature_name must be done using a dictionary. A dictionary can be
            used to specify numbers of lags for specific features. Features that
            are not specified will default to 1 lag feature.

            .. note ::
                The num_lags parameter will be overridden by the lags parameter per
                feature.

        rate_boundaries : dict, default None
            (Optional) For time series, specify the rate boundaries in the form
            {"feature" : {"min|max" : {order : value}}}. Works with partial values
            by specifying only particular order of derivatives you would like to
            overwrite. Invalid orders will be ignored.

            Examples::

                {
                    "stock_value": {
                        "min": {
                            '0' : 0.178,
                            '1': 3.4582e-3,
                            '2': None
                        }
                    }
                }

        Returns
        -------
        FeautreAttributesBase

        A subclass of FeatureAttributesBase that extends `dict`, thus providing
        dict-like access to feature attributes and useful helper methods.
        """
        tight_bound_features = None
        if tight_time_bounds:
            if features:
                if self.time_feature_name in features:
                    tight_bound_features = [self.time_feature_name]
            # if features weren't provided, just set to the specified
            # time_feature_name if that has also been provided
            elif self.time_feature_name:
                tight_bound_features = [self.time_feature_name]

        if tight_bounds is not None:
            tight_bound_features.extend(tight_bounds)

        infer = InferFeatureAttributesDataFrame(self.data)

        if mode_bound_features is None:
            feature_names = infer._get_feature_names()
            mode_bound_features = [
                f for f in feature_names if f != self.time_feature_name
            ]

        features = infer(
            features=features,
            infer_bounds=infer_bounds,
            dropna=dropna,
            datetime_feature_formats=datetime_feature_formats,
            attempt_infer_extended_nominals=attempt_infer_extended_nominals,
            nominal_substitution_config=nominal_substitution_config,
            include_extended_nominal_probabilities=include_extended_nominal_probabilities,
            id_feature_name=id_feature_name,
            tight_bounds=set(tight_bound_features) if tight_bound_features else None,
            mode_bound_features=mode_bound_features,
        )

        # Add any features with unsupported data to this object's list
        self.unsupported.extend(features.unsupported)

        if isinstance(time_invariant_features, str):
            time_invariant_features = [time_invariant_features]
        elif isinstance(time_invariant_features, Iterable):
            time_invariant_features = time_invariant_features
        else:
            time_invariant_features = []

        if isinstance(id_feature_name, str):
            id_feature_names = [id_feature_name]
        elif isinstance(id_feature_name, Iterable):
            id_feature_names = id_feature_name
        else:
            id_feature_names = []

        # ID features are time-invariant and cannot be NaN.
        for id_feature in id_feature_names:
            if id_feature not in time_invariant_features:
                time_invariant_features.append(id_feature)
            features[id_feature]["dropna"] = True

        if self.time_feature_name in time_invariant_features:
            raise ValueError('time_feature_name cannot be in the '
                             'time_invariant_features list.')

        # Set all non time invariant features to be `time_series` features
        for f_name, _ in features.items():
            # Mark all features which are completely NaN as time-invariant.
            if self.data[f_name].isnull().sum() == len(self.data[f_name]):
                time_invariant_features.append(f_name)

            if time_invariant_features == [] or f_name not in time_invariant_features:
                if time_series_types_override and f_name in time_series_types_override:
                    features[f_name]['time_series'] = {
                        'type': time_series_types_override[f_name]
                    }
                else:
                    features[f_name]['time_series'] = {
                        'type': time_series_type_default,
                    }

        if num_lags is not None:
            if isinstance(num_lags, int):
                for f_name, _ in features.items():
                    if f_name != self.time_feature_name and 'time_series' in features[f_name]:
                        features[f_name]['time_series']['num_lags'] = int(num_lags)
            elif isinstance(num_lags, dict):
                for f_name, f_lags in num_lags.items():
                    if 'time_series' in features[f_name]:
                        features[f_name]['time_series']['num_lags'] = int(f_lags)
        if lags is not None:
            if isinstance(lags, list):
                for f_name, _ in features.items():
                    if f_name != self.time_feature_name and 'time_series' in features[f_name]:
                        if 'num_lags' in features[f_name]['time_series']:
                            del features[f_name]['time_series']['num_lags']
                        features[f_name]['time_series']['lags'] = lags
            elif isinstance(lags, dict):
                for f_name, f_lags in lags.items():
                    # If lag_list is specified, lags is not used
                    if 'num_lags' in features[f_name]['time_series']:
                        del features[f_name]['time_series']['num_lags']
                    features[f_name]['time_series']['lags'] = f_lags

        if self.time_feature_name in features:
            features[self.time_feature_name]['time_series']['time_feature'] = True
            # Force time_feature to be `continuous`
            features[self.time_feature_name]['type'] = "continuous"
            # Set time_series as 'delta' so that lag and delta are computed
            features[self.time_feature_name]['time_series']['type'] = "delta"
            # Time feature might have `sensitive` and `subtype` attribute
            # which is not applicable to time feature.
            features[self.time_feature_name].pop('subtype', None)
            # The time feature cannot be NaN, so set dropna to True.
            features[self.time_feature_name]["dropna"] = True

            # if the time feature has no datetime and is stored as a string,
            # convert to an int for comparison since it's continuous
            if (
                    'date_time_format' not in features[self.time_feature_name] and
                    is_string_dtype(self.data[self.time_feature_name].dtype)
            ):
                self.data[self.time_feature_name] = self.data[self.time_feature_name].astype(float)

            # time feature cannot be null
            if 'bounds' in features[self.time_feature_name]:
                features[self.time_feature_name]['bounds']['allow_null'] = False
            else:
                features[self.time_feature_name]['bounds'] = {'allow_null': False}

        features = self._infer_delta_min_and_max(
            features=features,
            datetime_feature_formats=datetime_feature_formats,
            id_feature_name=id_feature_name,
            orders_of_derivatives=orders_of_derivatives,
            derived_orders=derived_orders
        )

        # Set any manually specified rate/delta boundaries
        if delta_boundaries is not None:
            self._set_rate_delta_bounds('delta', delta_boundaries, features)
        if rate_boundaries is not None:
            self._set_rate_delta_bounds('rate', rate_boundaries, features)

        return features

    def __call__(self, **kwargs) -> SingleTableFeatureAttributes:
        """Process and return feature attributes."""
        feature_attributes = self._process(**kwargs)
        # Put the time_feature_name back into the kwargs dictionary.
        kwargs["time_feature_name"] = self.time_feature_name
        return SingleTableFeatureAttributes(feature_attributes, params=kwargs, unsupported=self.unsupported)

howso/utilities/feature_attributes/time_series.py
howsoai/howso-engine-py
import decimal
from enum import Enum
from functools import partial
import locale
from typing import Any, Dict, Iterable, List, Optional, Union
import warnings

from howso.openapi.models import FeatureAttributes
import numpy as np
import pandas as pd
from pandas.core.dtypes.common import (
    is_datetime64_any_dtype,
    is_timedelta64_dtype,
)
import pytz

from .internals import (
    deserialize_to_dataframe,
    IgnoreWarnings,
    to_pandas_datetime_format
)
from .utilities import (
    DATETIME_TIMEZONE_PATTERN,
    LocaleOverride,
    seconds_to_time,
    serialize_datetimes,
)


__all__ = [
    'FeatureSerializer',
    'FeatureType',
    'deserialize_cases',
    'format_dataframe',
    'serialize_cases',
]


class FeatureType(Enum):
    """Feature type enum."""

    UNKNOWN = 'object'
    STRING = 'string'
    NUMERIC = 'numeric'
    INTEGER = 'integer'
    BOOLEAN = 'boolean'
    DATETIME = 'datetime'
    DATE = 'date'
    TIME = 'time'
    TIMEDELTA = 'timedelta'

    def __str__(self):
        """Return a string representation."""
        return str(self.value)


class FeatureSerializer:
    """Adapter for serialization and deserialization of feature data."""

    @classmethod
    def serialize(  # noqa: C901
        cls,
        data: Union[pd.DataFrame, np.ndarray, Iterable[Any]],
        columns: Iterable[str],
        features: Dict,
        *,
        warn: bool = False
    ) -> Union[List[List[Any]], None]:
        """
        Serialize case data into list of lists.

        Parameters
        ----------
        data : pandas.DataFrame or numpy.ndarray or list of list
            The data to serialize.
        columns : list of str
            The case column mapping. The order corresponds to the order of cases
            in output.
        features : dict
            The dictionary of feature name to feature attributes.
        warn : bool, default False
            If warnings should be raised by serializer.

        Returns
        -------
        list of list or None
            The serialized data from DataFrame.
        """
        # Import locally to prevent a circular import
        from howso.client.exceptions import HowsoError

        if data is None:
            return None
        if isinstance(data, np.ndarray):
            if columns is None:
                raise HowsoError(
                    "Columns must be provided with numpy arrays."
                )
            data = pd.DataFrame.from_records(data, columns=columns)

        if isinstance(data, pd.DataFrame):
            if columns is not None:
                columns = list(columns)
                try:
                    filtered_data = data[columns]
                except KeyError as key_error:
                    raise HowsoError(
                        "The provided DataFrame is missing one or more of the "
                        f"expected named columns: {columns}."
                    ) from key_error
            else:
                columns = data.columns.tolist()
                filtered_data = data

            data_columns = []
            for col in columns:
                col_data = filtered_data[col]
                if isinstance(col_data, pd.DataFrame):
                    raise ValueError(
                        "The provided DataFrame contains duplicate column "
                        "names. All column names must be unique.")
                dtype = col_data.dtype
                if is_datetime64_any_dtype(dtype):
                    # `to_pydatetime` emits a FutureWarning even when the issue
                    # is fixed.
                    with IgnoreWarnings(FutureWarning):
                        # Make sure datetimes are returned as python datetimes
                        data_columns.append(np.array(col_data.dt.to_pydatetime()))
                elif is_timedelta64_dtype(dtype):
                    # Make sure timedeltas are returned as python timedeltas
                    data_columns.append(col_data.dt.to_pytimedelta())
                else:
                    data_columns.append(col_data.to_numpy())
            result = np.array(data_columns).T.tolist()

        elif isinstance(data, Iterable) and not isinstance(data, str):
            if columns is None:
                raise HowsoError(
                    "Columns must be provided with python Iterables."
                )
            result = list(data)
        else:
            raise ValueError(
                "Received unexpected data type for case data. Cases "
                "should be either a DataFrame or a list.")

        # Convert 1d list (single case) to 2d list for serialization
        if len(result) > 0 and len(np.array(result).shape) == 1:
            result = [result]

        # Serialize datetime objects
        serialize_datetimes(result, columns, features, warn=warn)

        return result

    @classmethod
    def deserialize(
        cls,
        data: Union[Iterable[Iterable[Any]], Iterable[Dict[str, Any]]],
        columns: Iterable[str],
        features: Optional[Dict] = None
    ) -> pd.DataFrame:
        """
        Deserialize case data into a DataFrame.

        If feature attributes contain original typing information, columns
        will be converted to the same data type as original training cases.

        Parameters
        ----------
        data : list of list or list of dict
            The context data.
        columns : list of str
            The case column mapping.

            The order corresponds to how the data will be mapped to columns in
            the output. Ignored for list of dict where the dict key is the column
            name.
        features : dict, default None
            (Optional) The dictionary of feature name to feature attributes.

            If not specified, no column typing will be attempted.

        Returns
        -------
        pandas.DataFrame
            The deserialized data.
        """
        df = deserialize_to_dataframe(data, columns)
        if features is not None:
            cls.format_dataframe(df, features)
        return df

    @classmethod
    def format_dataframe(cls, df: pd.DataFrame, features: Dict
                         ) -> pd.DataFrame:
        """
        Format DataFrame columns to original type using feature attributes.

        .. note::

            Modifies DataFrame in place.

        Parameters
        ----------
        df : pandas.DataFrame
            The DataFrame to format columns of.
        features : Dict
            The dictionary of feature name to feature attributes.

        Returns
        -------
        pandas.DataFrame
            The formatted data.
        """
        for col in df.columns.tolist():
            try:
                attributes = features[col]
            except (TypeError, KeyError):
                # Column not in feature attributes, skip column
                continue
            df[col] = cls.format_column(df[col], attributes)
        return df

    @classmethod
    def format_column(cls, column: pd.Series,  # noqa: C901
                      feature: Union[Dict, FeatureAttributes]) -> pd.Series:
        """
        Format column based on feature typing information.

        Parameters
        ----------
        column : pandas.Series
            The column to format.
        feature : dict or FeatureAttributes
            The feature attributes for the column.

        Returns
        -------
        pandas.Series
            The formatted column.
        """
        if isinstance(feature, FeatureAttributes):
            feature = feature.to_dict()
        elif feature is None:
            feature = {}
        typing_info = cls._get_typing_info(feature)
        data_type = typing_info.get('data_type')

        if data_type == FeatureType.NUMERIC.value:
            return cls.format_numeric_column(column, feature)
        elif data_type == FeatureType.INTEGER.value:
            return cls.format_integer_column(column, feature)
        elif data_type == FeatureType.STRING.value:
            return cls.format_string_column(column, feature)
        elif data_type == FeatureType.DATETIME.value:
            return cls.format_datetime_column(column, feature)
        elif data_type == FeatureType.DATE.value:
            return cls.format_date_column(column, feature)
        elif data_type == FeatureType.TIME.value:
            return cls.format_time_column(column, feature)
        elif data_type == FeatureType.TIMEDELTA.value:
            return cls.format_timedelta_column(column, feature)
        elif data_type == FeatureType.BOOLEAN.value:
            return cls.format_boolean_column(column, feature)
        else:
            return cls.format_unknown_column(column, feature)

    @classmethod
    def format_timedelta_column(cls, column: pd.Series, feature: Dict
                                ) -> pd.Series:
        """
        Format timedelta column.

        Parameters
        ----------
        column : pandas.Series
            The column to format.
        feature : dict
            The feature attributes for the column.

        Returns
        -------
        pandas.Series
            The formatted column.
        """
        typing_info = cls._get_typing_info(feature)
        try:
            unit = typing_info['unit']
            if unit not in ['days', 'seconds', 'nanoseconds']:
                raise ValueError('invalid unit type')
        except (TypeError, KeyError, ValueError):
            warnings.warn(
                f'Unknown timedelta unit for column "{column.name}", '
                'column will not be translated to a timedelta64.')
            return column

        try:
            return pd.to_timedelta(column, unit=unit)
        except Exception:  # noqa: Deliberately broad
            warnings.warn(
                f'Timedelta column "{column.name}" failed to be parsed as '
                'timedelta64 values, column will retain current dtype.'
            )
        return column

    @classmethod
    def format_datetime_column(cls, column: pd.Series, feature: Dict  # noqa: C901
                               ) -> pd.Series:
        """
        Format datetime column.

        Parameters
        ----------
        column : pandas.Series
            The column to format.
        feature : dict
            The feature attributes for the column.

        Returns
        -------
        pandas.Series
            The formatted column.
        """
        typing_info = cls._get_typing_info(feature)
        date_time_format = feature.get('date_time_format') or None
        date_locale = feature.get('locale') or None
        format_includes_timezone = False
        if (
            date_time_format and
            DATETIME_TIMEZONE_PATTERN.findall(date_time_format)
        ):
            format_includes_timezone = True

        # Load timezone information if provided
        tz = typing_info.get('timezone')
        if tz is not None:
            try:
                tz = pytz.timezone(tz)
            except pytz.UnknownTimeZoneError:
                warnings.warn(
                    f'Unknown timezone "{tz}" for feature "{column.name}", '
                    'datetime column may not contain original timezone '
                    'information.')
                tz = None

        def _to_datetime():
            # Format column to datetime
            target_format = to_pandas_datetime_format(date_time_format)
            if tz is not None:
                if format_includes_timezone:
                    # When format string includes timezone we want to load
                    # the datetime as UTC, then we can convert it to the named
                    # timezone
                    col = pd.to_datetime(column, format=target_format, utc=True)
                    return col.dt.tz_convert(tz)
                else:
                    # Since format string does not include timezone
                    # we just have to localize the datetime object
                    col = pd.to_datetime(column, format=target_format)
                    return col.dt.tz_localize(tz)

            return pd.to_datetime(column, format=target_format)

        try:
            if date_locale:
                with LocaleOverride(date_locale, category=locale.LC_TIME):
                    return _to_datetime()
            else:
                return _to_datetime()
        except locale.Error:
            warnings.warn(
                f'Unable to parse column "{column.name}" as datetime64, the '
                f'locale "{date_locale}" of the datetime does not appear to be '
                'available on this system. Column will retain current dtype. '
                'Note: This feature may not work properly until this locale '
                'is installed.')
        except Exception:  # noqa: Deliberately broad
            warnings.warn(f'Unable to parse column "{column.name}" as '
                          f'datetime64 using format "{date_time_format}". '
                          'Column will retain current dtype.')
        return column

    @classmethod
    def format_date_column(cls, column: pd.Series, feature: Dict
                           ) -> pd.Series:
        """
        Format date only column.

        Parameters
        ----------
        column : pandas.Series
            The column to format.
        feature : dict
            The feature attributes for the column.

        Returns
        -------
        pandas.Series
            The formatted column.
        """
        date_time_format = feature.get('date_time_format') or None
        date_locale = feature.get('locale') or None
        target_format = to_pandas_datetime_format(date_time_format)
        try:
            if date_locale:
                with LocaleOverride(date_locale, category=locale.LC_TIME):
                    return pd.to_datetime(column, format=target_format)
            else:
                return pd.to_datetime(column, format=target_format)
        except locale.Error:
            warnings.warn(
                f'Unable to parse column "{column.name}" as datetime64, the '
                f'locale "{date_locale}" of the date does not appear to be '
                'available on this system. Column will retain current dtype. '
                'Note: This feature may not work properly until this locale '
                'is installed.')
        except Exception:  # noqa: Deliberately broad
            warnings.warn(f'Unable to parse column "{column.name}" as '
                          f'datetime64 using format {date_time_format}. '
                          'Column will retain current dtype.')
        return column

    @classmethod
    def format_time_column(cls, column: pd.Series, feature: Dict  # noqa: C901
                           ) -> pd.Series:
        """
        Format time only column.

        Parameters
        ----------
        column : pandas.Series
            The column to format.
        feature : dict
            The feature attributes for the column.

        Returns
        -------
        pandas.Series
            The formatted column.
        """
        typing_info = cls._get_typing_info(feature)
        date_time_format = feature.get('date_time_format') or None
        if date_time_format:
            return cls.format_datetime_column(column, feature)
        else:
            # Time feature does not use a date_time_format, treat as seconds
            try:
                tz = typing_info['timezone']
                if tz is not None:
                    tz = pytz.timezone(tz)
            except pytz.UnknownTimeZoneError:
                tz = None
                warnings.warn(
                    f'Unknown timezone defined for column "{column.name}", '
                    'column will not be timezone aware.')
            except (TypeError, KeyError):
                tz = None

            return column.apply(partial(seconds_to_time, tzinfo=tz))

    @classmethod
    def format_boolean_column(cls, column: pd.Series, feature: Dict
                              ) -> pd.Series:
        """
        Format boolean column.

        Parameters
        ----------
        column : pandas.Series
            The column to format.
        feature : dict
            The feature attributes for the column.

        Returns
        -------
        pandas.Series
            The formatted column.
        """
        try:
            def _apply_bool(value):
                if isinstance(value, str):
                    return value.lower() in ["true", "yes", "on", "(true)"]
                return bool(value)

            return column.apply(_apply_bool)
        except Exception:  # noqa: Deliberately broad
            warnings.warn(f'Unable to parse column "{column.name}" as '
                          f'boolean. Column will retain current dtype.')
            return column

    @classmethod
    def format_integer_column(cls, column: pd.Series, feature: Dict
                              ) -> pd.Series:
        """
        Format integer column.

        Parameters
        ----------
        column : pandas.Series
            The column to format.
        feature : dict
            The feature attributes for the column.

        Returns
        -------
        pandas.Series
            The formatted column.
        """
        typing_info = cls._get_typing_info(feature)

        has_nulls = column.isnull().any()
        if has_nulls:
            # Use pandas extension type for nullable integer
            type_name = 'Int'
        else:
            type_name = 'int'

        size = typing_info.get('size')
        if size in [1, 2, 4, 8]:
            type_name = f'{type_name}{size * 8}'
        unsigned = typing_info.get('unsigned')
        if unsigned:
            type_name = f'U{type_name}' if has_nulls else f'u{type_name}'

        # unique or int-id features are output as 64bit ints, leave them
        # as-is if original datatype is less than 64 bits
        if size < 8 and (getattr(feature, 'unique', False) or
                         getattr(feature, 'subtype', None) == 'int-id'):
            warnings.warn(f'Unable to restore column "{column.name}" as '
                          f'{type_name}. Column will retain current dtype.')
            return column

        try:
            # We use floor and to_numeric here to handle cases where the value
            # may be a string of float like values, so we get the column as a
            # generic number first, remove the decimal places and then set the
            # type to the expected format
            return np.floor(pd.to_numeric(column)).astype(type_name)
        except Exception:  # noqa: Deliberately broad
            warnings.warn(f'Unable to parse column "{column.name}" as '
                          f'{type_name}. Column will retain current dtype.')
            return column

    @classmethod
    def format_numeric_column(cls, column: pd.Series, feature: Dict
                              ) -> pd.Series:
        """
        Format numeric column.

        Parameters
        ----------
        column : pandas.Series
            The column to format.
        feature : dict
            The feature attributes for the column.

        Returns
        -------
        pandas.Series
            The formatted column.
        """
        typing_info = cls._get_typing_info(feature)
        number_format = typing_info.get('format')
        type_name = 'float'

        try:
            if number_format == 'decimal':
                type_name = 'Decimal'
                # Note: We cast to string first here in attempt to prevent
                # floating point errors.
                return column.apply(lambda x: decimal.Decimal(
                    str(x)) if x is not None else None)
            else:
                type_name = 'float'
                size = typing_info.get('size')
                if size in [1, 2, 4, 8, 16]:
                    type_name = f'{type_name}{size * 8}'

                # unique or int-id features are output as 64bit ints, leave them
                # as-is if original datatype is less than 64 bits
                if size < 8 and (getattr(feature, 'unique', False) or
                                 getattr(feature, 'subtype', None) == 'int-id'):
                    warnings.warn(
                        f'Unable to restore column "{column.name}" as '
                        f'{type_name}. Column will retain current dtype.'
                    )
                    return column

                return column.astype(type_name)
        except Exception:  # noqa: Deliberately broad
            warnings.warn(f'Unable to parse column "{column.name}" as '
                          f'{type_name}. Column will retain current dtype.')
            return column

    @classmethod
    def format_string_column(cls, column: pd.Series, feature: Dict
                             ) -> pd.Series:
        """
        Format string column.

        Parameters
        ----------
        column : pandas.Series
            The column to format.
        feature : dict
            The feature attributes for the column.

        Returns
        -------
        pandas.Series
            The formatted column.
        """
        # Nothing to do for string columns
        return column

    @classmethod
    def format_unknown_column(cls, column: pd.Series, feature: Dict
                              ) -> pd.Series:
        """
        Format unknown typed column.

        Parameters
        ----------
        column : pandas.Series
            The column to format.
        feature : dict
            The feature attributes for the column.

        Returns
        -------
        pandas.Series
            The formatted column.
        """
        # Unknown original type, don't modify column
        return column

    @staticmethod
    def _get_typing_info(feature: Optional[Dict]) -> Dict:
        """
        Get typing info from feature attributes.

        Parameters
        ----------
        feature : Dict or None
            The feature attributes.

        Returns
        -------
        dict
            The typing info for the feature. Or empty dict if none found.
        """
        try:
            typing_info = feature['original_type']
        except (TypeError, KeyError):
            return dict()
        return typing_info or dict()


serialize_cases = FeatureSerializer.serialize
deserialize_cases = FeatureSerializer.deserialize
format_dataframe = FeatureSerializer.format_dataframe

howso/utilities/features.py
howsoai/howso-engine-py
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import IntEnum
from functools import cached_property, partial
import inspect
from io import StringIO
import logging
import math
import multiprocessing
from pathlib import Path
import random
import sys
import traceback
from typing import Callable, Iterable, List, Optional, Tuple, Union
import warnings

from faker.config import AVAILABLE_LOCALES
try:
    from howso import engine
except ImportError:
    engine = None
from howso.client import (
    AbstractHowsoClient, HowsoClient
)
from howso.client.exceptions import HowsoConfigurationError, HowsoError
from howso.openapi.models import Trainee
try:
    from howso.validator import Validator  # noqa: might not be available # type: ignore
except OSError as e:
    Validator = e
except ImportError:
    Validator = None
from howso.utilities import infer_feature_attributes
try:
    from howso.synthesizer import Synthesizer  # noqa: might not be available # type: ignore
except ImportError:
    Synthesizer = None
from howso.utilities import StopExecution, Timer
from howso.utilities.locale import get_default_locale
from howso.utilities.posix import PlatformError, sysctl_by_name
import pandas as pd
try:
    from requests.exceptions import ConnectionError
except ImportError:
    ConnectionError = None
from rich import print
from rich.progress import (
    BarColumn,
    Progress,
    TaskProgressColumn,
    TextColumn,
    TimeElapsedColumn,
)
from sklearn.model_selection import train_test_split

logger = logging.getLogger(__name__)

LOG_FILE = "howso_stacktrace.txt"


class Status(IntEnum):
    """Status Enum."""

    CRITICAL = 0
    ERROR = 1
    WARNING = 2
    NOTICE = 3
    OK = 4


Requirements = Iterable[Union[type, object]]


@dataclass
class Check:
    """Store the specification of a single check."""

    name: str
    fn: Callable
    client_required: Optional[str] = None
    other_requirements: Optional[Requirements] = None


class InstallationCheckRegistry:
    """Simple registry and executor of verification tests."""

    def __init__(self):
        """Initialize CheckRegistry."""
        self._checks = []

        # Storage for property caches
        self._client = None
        self._client_classes = []

        # This is where we'll write any stack traces.
        self.logger = StringIO()

        # Adds the first check for Python
        self.add_check(
            name="Python: Running correctly (not under emulation)",
            fn=check_not_emulated
        )
        # And the next check which builds a howso client
        self.add_check(
            name="Howso Client: Configuration",
            fn=self._check_client_configuration
        )

    def add_check(self, name: str,
                  fn: Callable,
                  client_required: Optional[str] = None,
                  other_requirements: Optional[Requirements] = None
                  ):
        """
        Add a check for this installation.

        Parameters
        ----------
        name : str
            The name to display for the check.
        fn : Callable
            The callable to run to perform the check.
        client_required : str, default None
            Optional. If set should be the class name of the client required.
        other_requirements : Iterable of classes or objects, default None
            Optional. Other required classes, E.g., `Synthesizer`. Note, these
            are not strings. These are the classes or instances of things
            required. They should have been imported in a try/catch and sent
            to something falsy if not imported.
        """
        if (
            other_requirements and
            not isinstance(other_requirements, Iterable)
        ):
            other_requirements = [other_requirements]
        self._checks.append(
            Check(name=name, fn=fn, client_required=client_required,
                  other_requirements=other_requirements))

    @cached_property
    def _name_length(self) -> int:
        """
        Compute the max *rendered* length among all check names.

        Returns
        -------
        int
            The maximum length of names of checks.
        """
        if len(self._checks):
            return max((len(c.name) for c in self._checks))
        else:
            return 1

    @property
    def client(self) -> AbstractHowsoClient:
        """
        Return a lazily-instantiated and cached client to use.

        Returns
        -------
        AbstractHowsoClient
            A instance of AbstractHowsoClient determined by the
            user's configuration.
        """
        if self._client is None:
            self._client = HowsoClient(debug=0)
        return self._client

    @property
    def client_classes(self) -> List[str]:
        """Return list of super class names for the current cached client."""
        if self._client is None:
            return []
        if self._client_classes == []:
            self._client_classes = [
                c.__name__ for c in inspect.getmro(type(self._client))]

        return self._client_classes

    @staticmethod
    def _check_client_configuration(registry):
        """
        Check that the Howso client can be instantiated.

        This is intended to be among the first checks, so it is a built-in.

        Parameters
        ----------
        registry : The InstallationCheckRegistry
            The registry used to run this check.

        Returns
        -------
        tuple
            Status
                The status of the check as OK, WARNING, ERROR or CRITICAL.
            str
                A message to display about the WARNING, ERROR or CRITICAL.
        """
        try:
            registry._client = HowsoClient(debug=0)
        except HowsoConfigurationError:
            traceback.print_exc(file=registry.logger)
            return (
                Status.CRITICAL,
                "The howso configuration file was not found in the "
                "location that was specified in the `HOWSO_CONFIG` environment "
                "variable. Please see the Howso Client installation "
                "documentation for further details."
            )
        except PermissionError:
            traceback.print_exc(file=registry.logger)
            return (
                Status.CRITICAL,
                "Howso Client could not be started due to file "
                "permissions. Please see the Howso Client installation "
                "documentation for further details."
            )
        except (ModuleNotFoundError, StopExecution):
            traceback.print_exc(file=registry.logger)
            return (
                Status.CRITICAL,
                "Unable to connect to a Howso Platform. Please ensure "
                "that you have a valid `howso.yml` file in the correct "
                "location. Please see the Howso Client installation "
                "documentation for further details."
            )
        except ValueError:
            traceback.print_exc(file=registry.logger)
            return (Status.CRITICAL,
                    "The client was unable for find Howso core binaries. "
                    "Please see the Howso Client installation "
                    "documentation for further details.")
        except Exception as e:  # noqa: Deliberately broad
            if ConnectionError is not None and isinstance(e, ConnectionError):
                traceback.print_exc(file=registry.logger)
                return (
                    Status.CRITICAL,
                    "Unable to connect to the Howso Platform "
                    "configured in your `howso.yml` file. Please check for "
                    "configuration errors and/or network connectivity to the "
                    "platform host."
                )
            else:
                traceback.print_exc(file=registry.logger)
                return (
                    Status.CRITICAL,
                    "There was a problem instantiating the Howso client. "
                    "Please see the Howso Client installation documentation "
                    "for further details."
                )

        return (Status.OK, "")

    def run_checks(self) -> int:  # noqa: C901
        """
        Run each of the registered checks and output their status.

        Returns
        -------
        int
            The appropriate exit code to use at program end.
        """
        all_issues = 0
        critical_issues = 0

        progress = Progress(
            TextColumn("[progress.description]{task.description}"),
            BarColumn(), TaskProgressColumn(), TimeElapsedColumn())

        if not self.logger:
            self.logger = StringIO()
        start_time = datetime.now()

        try:
            with progress:
                for check in progress.track(self._checks):
                    if check.client_required:
                        if (
                            self._client is None or
                            check.client_required not in self.client_classes
                        ):
                            continue
                    if check.other_requirements:
                        if not all(check.other_requirements):
                            continue

                    progress.tasks[0].description = (
                        f"{check.name:{self._name_length}s}")

                    status, msg = check.fn(registry=self)
                    if status == Status.CRITICAL:
                        emoji = ":boom:"
                        color = "magenta"
                    elif status == Status.ERROR:
                        emoji = ":heavy_exclamation_mark:"
                        color = "red"
                    elif status == Status.WARNING:
                        emoji = ":warning:"
                        color = "yellow"
                    elif status == Status.NOTICE:
                        emoji = ":interrobang:"
                        color = "medium_turquoise"
                    else:  # status == Status.OK:
                        emoji = ":heavy_check_mark:"
                        color = "green"

                    if status < Status.NOTICE:
                        # This includes warnings
                        all_issues += 1

                    if status in [Status.CRITICAL, Status.ERROR]:
                        # This does not include warnings.
                        critical_issues += 1

                    if msg:
                        progress.console.print(
                            f"[bold]{check.name:{self._name_length}s} - "
                            f"[{color}]{status.name} {emoji} - {msg}")
                    else:
                        progress.console.print(
                            f"[bold]{check.name:{self._name_length}s} - "
                            f"[{color}]{status.name} {emoji}")

                progress.tasks[0].description = (
                    f"{'All checks complete':{self._name_length}s}")
        finally:
            # Write the contents of `logger`, if any, to a disk file.
            if self.logger:
                logs = self.logger.getvalue()
                self.logger.close()
                self.logger = None
                end_time = datetime.now()
                log_file = Path(".", LOG_FILE)
                if len(logs):
                    all_issues += 1
                    with open(log_file, mode="w+") as log:
                        print(f"Installation verification run: "
                              f"{start_time.isoformat()}\n" + "=" * 80 + "\n",
                              file=log)
                        print(logs, file=log)
                        print(f"Verification complete: {end_time.isoformat()} "
                              f"(elapsed time: {end_time - start_time})\n",
                              file=log)

        if not all_issues:
            print("[bold green]You are ready to use Howso™!")
        else:
            print("[bold yellow]There were one or more issues. Please review "
                  "the messages emitted during the installation verification "
                  "process to identify next steps. If you cannot resolve "
                  "these issues please do not hesitate to contact your "
                  "Howso™ representative.")
            print(f'[bold yellow]Any CRITICAL issues are logged in the file '
                  f'"{LOG_FILE}" in the current directory.')

        # This is largely for automated systems.
        if critical_issues:
            return 255
        else:
            return 0


def get_nonce(length=8) -> str:
    """
    Return a string of `length` random hexadecimal digits.

    Parameters
    ----------
    length : int, default: 8
        The length of the returned string.

    Returns
    -------
    str
        A string representing a hexadecimal number of length `length`.
    """
    return f"{random.randint(0, 16 ** length):0{length}x}"


def generate_dataframe(*, client: AbstractHowsoClient,
                       num_samples: int = 150,
                       timeout: Optional[int] = None
                       ) -> Tuple[pd.DataFrame, Union[float, int]]:
    """
    Use HowsoClient to create a dataframe of random data.

    Parameters
    ----------
    client : AbstractHowsoClient
        The Howso client instance to use.
    num_samples : int, default 150
        The number of samples to synthesize.
    timeout : int or None, default None
        Optional. If provided, `num_samples` is ignored and synthesis happens
        1 record at a time until `timeout` seconds have elapsed.

    Returns
    -------
    pd.DataFrame
        A dataframe of the synthesized records.

    """
    continuous_feature = {
        "type": "continuous",
        "bounds": {
            "min": 0.0,
            "max": 100.0,
            "decimal_places": 2,
            "allow_null": False,
        }
    }
    features = {
        "alpha": continuous_feature,
        "beta": continuous_feature,
        "gamma": continuous_feature,
        "class": {
            "type": "nominal",
            "bounds": {
                "allowed": ["apple", "banana", "cherry"],
                "allow_null": False},
        }
    }
    feature_names = list(features.keys())
    context_features = list(feature_names)[:-1]
    action_features = [list(feature_names)[-1]]

    trainee_obj = Trainee(
        f"installation_verification generated dataframe ({get_nonce()})",
        features=features,
        default_context_features=context_features,
        default_action_features=action_features,
        persistence="never"
    )
    trainee = client.create_trainee(trainee_obj)
    if not isinstance(trainee, Trainee):
        raise HowsoError('Unable to create trainee.')
    client.set_feature_attributes(trainee.id, features)
    client.acquire_trainee_resources(trainee.id, max_wait_time=0)
    if timeout:
        # Generate 1 case at a time until `timeout` has passed.
        end_time = datetime.now() + timedelta(seconds=timeout)
        cases = {"action": []}
        while datetime.now() < end_time:
            if case := client.react(
                trainee.id, action_features=feature_names,
                num_cases_to_generate=1, desired_conviction=1.0,
                generate_new_cases="no", suppress_warning=True
            ):
                cases["action"].append(case["action"][0])
        elapsed_time = timeout
    else:
        with Timer() as timer:
            cases = client.react(
                trainee.id, action_features=feature_names,
                num_cases_to_generate=num_samples, desired_conviction=1.0,
                generate_new_cases="no", suppress_warning=True
            ) or {"action": []}
        elapsed_time = timer.seconds or math.nan
    client.delete_trainee(trainee.id)
    df = pd.DataFrame(cases["action"], columns=feature_names)
    return df, elapsed_time


def check_not_emulated(*, registry: InstallationCheckRegistry):
    """
    Check that the installation is not running under emulation on MacOS.

    This simply passed under other operating systems.

    Parameters
    ----------
    registry : The InstallationCheckRegistry
        The registry used to run this check.

    Returns
    -------
    tuple
        Status
            The status of the check as OK, WARNING, ERROR or CRITICAL.
        str
            A message to display about the WARNING, ERROR or CRITICAL result.
    """
    if sys.platform == "darwin":
        try:
            proc_translated = sysctl_by_name('sysctl.proc_translated', 'int')
        except PlatformError:
            return (Status.OK, "")
        except Exception:  # Deliberatey broad
            return (Status.WARNING, "Unable to check if running under emulation.")
        if proc_translated == 1:
            # Python is running under Rosetta. Advise the user install the
            # correct Python.
            return (
                Status.WARNING,
                "Python is running under emulation on this system. This might "
                "happen if the wrong installer was used to install Python. It "
                "is **strongly** advised that Python is reinstalled using a "
                "\"Universal Installer\" before proceeding.")

    return (Status.OK, "")


def check_generate_dataframe(*, registry: InstallationCheckRegistry,
                             threshold: Optional[float] = None):
    """
    Rate the speed in which a dataframe was able to be generated.

    Parameters
    ----------
    registry : The InstallationCheckRegistry
        The registry used to run this check.
    threshold : float or None, default None
        Optional. If provided determines how long the process can run before
        considering it to return a status of WARNING.

    Returns
    -------
    tuple
        Status
            The status of the check as OK, WARNING, ERROR or CRITICAL.
        str
            A message to display about the WARNING, ERROR or CRITICAL result.
    """
    try:
        _, duration = generate_dataframe(client=registry.client,
                                         num_samples=150)
    except ValueError:
        traceback.print_exc(file=registry.logger)
        return (Status.CRITICAL,
                "The client was unable for find Howso core binaries. "
                "Please see the Howso Client installation documentation "
                "for further details.")
    if threshold is not None and duration > threshold:
        return (Status.WARNING,
                f"The client required a duration of {duration:,.1f} to "
                f"synthesize a DataFrame, this should require no more than "
                f"{threshold:,.1f} seconds. This warning may be expected in "
                f"auto-scaling installations.")
    else:
        return (Status.OK, "")


def check_basic_synthesis(*, registry: InstallationCheckRegistry,
                          source_df=None):
    """
    Validate that Synthesizer can perform a basic synthesis.

    Parameters
    ----------
    registry : The InstallationCheckRegistry
        The registry used to run this check.
    source_df : pd.DataFrame or None, default None
        Optional. If not provided a new dataframe will be synthesized.

    Returns
    -------
    tuple
        Status
            The status of the check as OK, WARNING, ERROR or CRITICAL.
        str
            A message to display about the WARNING, ERROR or CRITICAL result.
    """
    try:
        # Using a made-up dataframe, synthesize a new one from it.
        if source_df is None:
            source_df, _ = generate_dataframe(client=registry.client)

        features = infer_feature_attributes(source_df)
        if not Synthesizer:
            raise AssertionError("Howso Synthesizer™ is not installed.")
        with Synthesizer(client=registry.client, privacy_override=True) as s:
            s.train(source_df, features)
            source_df = s.synthesize_cases(n_samples=100)
        if source_df.shape != (100, 4):
            return (Status.CRITICAL, "Synthetic dataframe is the wrong shape.")
    except Exception:  # noqa: Deliberately broad
        traceback.print_exc(file=registry.logger)
        return (Status.CRITICAL,
                "Could not complete check. Check installation.")
    else:
        return (Status.OK, "")


def check_locales_available(*, registry: InstallationCheckRegistry):
    """
    Check that default locale is available in faker.

    Parameters
    ----------
    registry : The InstallationCheckRegistry
        The registry used to run this check.

    Returns
    -------
    tuple
        Status
            The status of the check as OK, WARNING, ERROR or CRITICAL.
        str
            A message to display about the WARNING, ERROR or CRITICAL result.
    """
    try:
        if (default_locale := get_default_locale()[0]) not in AVAILABLE_LOCALES:
            return (
                Status.WARNING,
                f"Current locale, {default_locale} is not available in Faker "
                f"(https://faker.readthedocs.io/en/master/locales.html). "
                f"The locale for Faker will be set to 'en_US'."
            )
    except Exception:  # noqa: Deliberately broad
        traceback.print_exc(file=registry.logger)
        return (Status.CRITICAL,
                "Could not complete check. Check installation.")
    else:
        return (Status.OK, "")


def check_save(*, registry: InstallationCheckRegistry,
               source_df: Optional[pd.DataFrame] = None):
    """
    Ensure that a model can can be saved.

    Parameters
    ----------
    registry : The InstallationCheckRegistry
        The registry used to run this check.
    source_df : pd.DataFrame or None, default None
        Optional. If not provided a new dataframe will be synthesized.

    Returns
    -------
    tuple
        Status
            The status of the check as OK, WARNING, ERROR or CRITICAL.
        str
            A message to display about the WARNING, ERROR or CRITICAL result.
    """
    client = trainee = None
    try:
        client = registry.client
        if source_df is None:
            source_df, _ = generate_dataframe(client=client)
        features = infer_feature_attributes(source_df)
        feature_names = list(features.keys())
        trainee_obj = Trainee(
            f"installation_verification check save ({get_nonce()})",
            features=features
        )
        if trainee := client.create_trainee(trainee_obj):
            client.train(trainee.id, source_df, features=feature_names)
            client.persist_trainee(trainee.id)
        else:
            raise HowsoError("Could not create a trainee.")
    except Exception:  # noqa: Deliberately broad
        traceback.print_exc(file=registry.logger)
        return (Status.CRITICAL,
                "Could not save model. Please check file permissions.")
    else:
        return (Status.OK, "")
    finally:
        try:
            if client and trainee:
                client.delete_trainee(trainee.id)
        except Exception as e:  # noqa: Deliberately broad
            pass


def check_synthesizer_create_delete(*, registry: InstallationCheckRegistry,
                                    source_df: Optional[pd.DataFrame] = None):
    """
    Ensure that a model can can be created and deleted.

    Parameters
    ----------
    registry : The InstallationCheckRegistry
        The registry used to run this check.
    source_df : pd.DataFrame or None, default None
        Optional. If not provided a new dataframe will be synthesized.

    Returns
    -------
    tuple
        Status
            The status of the check as OK, WARNING, ERROR or CRITICAL.
        str
    """
    s = None
    try:
        if source_df is None:
            source_df, _ = generate_dataframe(client=registry.client)

        features = infer_feature_attributes(source_df)
        if not Synthesizer:
            raise AssertionError('Howso Synthesizer™ is not installed.')
        s = Synthesizer(client=registry.client, privacy_override=True)

        s.train(source_df[:50], features)
        n = s.cl.get_num_training_cases(s.trainee.id)
        if n != 50:
            return (Status.ERROR,
                    f"Training did not produce the correct number of "
                    f"training cases ({n}). Howso Synthesizer might not be "
                    "installed correctly. "
                    "Try: `pip install --upgrade howso-synthesizer`.")

        s.cl.delete_trainee(s.trainee.id)
    except Exception:  # noqa: Deliberately broad
        traceback.print_exc(file=registry.logger)
        return (Status.CRITICAL,
                "Could not complete check. Check installation. "
                "Try: `pip install --upgrade howso-synthesizer`.")
    else:
        return (Status.OK, "")
    finally:
        try:
            if s:
                s.cl.delete_trainee(s.trainee.id)
        except Exception:  # noqa: Deliberately broad
            pass


def check_latency(*, registry: InstallationCheckRegistry,
                  source_df: Optional[pd.DataFrame] = None,
                  notice_threshold: int = 10, warning_threshold: int = 20,
                  timeout: int = 10):
    """
    Ensure creation of `sample_threshold` requests within `timeout` seconds.

    # This test uses a deliberately inefficient manner to synthesize records
    # and is # done this way to expose network latency issues.

    Parameters
    ----------
    registry : The InstallationCheckRegistry
        The registry used to run this check.
    source_df : pd.DataFrame or None, default None
        Optional. If not provided a new dataframe will be synthesized.
    notice_threshold : int, default 10
        The number of samples that should be generated within `timeout`
        seconds. If it cannot generate this number within the timeout, then the
        resulting Status will be NOTICE.
    warning_threshold : int, default 20
        The number of samples that should be generated within `timeout`
        seconds. If it cannot generate this number within the timeout, then the
        resulting Status will be WARNING.
    timeout : int, default 10
        The number of seconds to run synthesis, one sample at a time.

    Returns
    -------
    tuple
        Status
            The status of the check as OK, WARNING, ERROR or CRITICAL.
        str
            A message to display about the WARNING, ERROR or CRITICAL result.
    """
    try:
        if source_df is None:
            source_df, _ = generate_dataframe(client=registry.client,
                                              timeout=timeout)
        num_rows = source_df.shape[0]
        if num_rows < warning_threshold:
            return (Status.WARNING,
                    f"{num_rows} records synthesized in {timeout:,d} seconds. "
                    f"A minimum of {warning_threshold:,d} samples expected. "
                    "Ensure a good network connection and that Howso "
                    "Platform is installed on sufficient cluster hardware. "
                    "In auto-scaling installations this may be due to slow node "
                    "start-ups.")
        elif num_rows < notice_threshold:
            return (Status.NOTICE,
                    f"{num_rows} records synthesized in {timeout:,d} seconds. "
                    f"Less than {notice_threshold:,d} may indicate "
                    "a poor network connection or slow/oversubscribed "
                    "cluster hardware. This notice is expected in auto-scaling "
                    "installations.")
    except Exception:  # noqa: Deliberately broad
        traceback.print_exc(file=registry.logger)
        return (Status.CRITICAL,
                "Could not complete check. Check installation.")
    else:
        return (Status.OK, "")


def check_performance(*, registry: InstallationCheckRegistry,
                      num_samples: int = 5_000, notice_threshold: float = 10.0,
                      warning_threshold: float = 20.0):
    """
    Ensure can generate `num_samples` records with `time_threshold` seconds.

    Parameters
    ----------
    registry : The InstallationCheckRegistry
        The registry used to run this check.
    num_samples : int, default 5,000
        The number of samples to generate.
    notice_threshold : float, default 10.0
        The notice time-threshold in seconds. If the generation of `num_samples`
        requires more than `threshold` seconds, the returned Status will be
        NOTICE.
    warning_threshold : float, default 20.0
        The warning time-threshold in seconds. If the generation of `num_samples`
        requires more than `threshold` seconds, the returned Status will be
        WARNING.

    Returns
    -------
    tuple
        Status
            The status of the check as OK, WARNING, ERROR or CRITICAL.
        str
            A message to display about the WARNING, ERROR or CRITICAL result.
    """
    try:
        _, num_seconds = generate_dataframe(client=registry.client,
                                            num_samples=num_samples)
        msg = (
            f"{num_samples:,d} records were synthesized in "
            f"{num_seconds:,.1f} seconds. ")
        if num_seconds > warning_threshold:
            return (
                Status.WARNING,
                msg + f" This should require fewer than {warning_threshold:,.1f} "
                "seconds. Ensure the installation is on equipment that meets "
                "Howso's recommended hardware specifications. "
                "In auto-scaling installations this may be due to slow node "
                "start-ups.")
        elif num_seconds > notice_threshold:
            return (
                Status.NOTICE,
                msg + f" Greater than {notice_threshold:,.1f} seconds may indicate "
                "slow or underpowered hardware. Ensure the installation is on "
                "equipment that meets Howso's recommended specifications. "
                "This notice is expected in auto-scaling installations.")
    except Exception:  # noqa: Deliberately broad
        traceback.print_exc(file=registry.logger)
        return (Status.CRITICAL,
                "Could not complete operation. Check installation.")
    else:
        return (Status.OK, "")


def check_engine_operation(
    *,
    registry: InstallationCheckRegistry,
    source_df: Optional[pd.DataFrame] = None
):
    """
    Ensure that Howso Engine operates as it should.

    Parameters
    ----------
    registry : The InstallationCheckRegistry
        The registry used to run this check.
    source_df : pd.DataFrame or None, default None
        Optional. If not provided a new dataframe will be synthesized.

    Returns
    -------
    tuple
        Status
            The status of the check as OK, WARNING, ERROR or CRITICAL.
        str
            A message to display about the WARNING, ERROR or CRITICAL result.
    """
    trainee = None
    try:
        if source_df is None:
            source_df, _ = generate_dataframe(client=registry.client,
                                              num_samples=150)

        features = infer_feature_attributes(source_df)
        X = source_df.drop("class", axis=1)
        y = source_df["class"]

        X_train, X_test, y_train, _ = train_test_split(X, y, test_size=0.2)
        action_features = ["class"]
        context_features = X.columns.tolist()
        if not engine:
            raise AssertionError("Howso Engine™ is not installed.")
        trainee = engine.Trainee(
            name=(f"installation_verification "
                  f"check engine operations ({get_nonce()})"),
            features=features, overwrite_existing=True
        )
        trainee.train(X_train.join(y_train))
        trainee.analyze()
        response = trainee.react(X_test, context_features=context_features,
                                 action_features=action_features)
        results = response['action'][action_features]
        if results.shape[0] != X_test.shape[0]:
            return (Status.ERROR,
                    "Results do not have the same number of samples as the "
                    "input data.")
    except Exception:  # noqa: Deliberately broad
        traceback.print_exc(file=registry.logger)
        return (Status.CRITICAL,
                "Could not complete operation. Check installation.")
    else:
        return (Status.OK, "")
    finally:
        try:
            if engine and trainee:
                engine.delete_trainee(trainee.id)
        except Exception:  # noqa: Deliberately broad
            pass


def check_validator_enterprise_desirability(
    *, registry: InstallationCheckRegistry,
    source_df: Optional[pd.DataFrame] = None,
    desirability_threshold: float = 2.0
):
    """
    Ensure that Validator-Enterprise operates as it should, returning relatively high desirability.

    Parameters
    ----------
    registry : The InstallationCheckRegistry
        The registry used to run this check.
    source_df : pd.DataFrame or None, default None
        Optional. If not provided a new dataframe will be synthesized.
    desirability_threshold : float, default 3.0
        The desirability threshold. If the run of Validator Enterprise
        returns a desirability less than this value, the returned
        Status will be WARNING.

    Returns
    -------
    tuple
        Status
            The status of the check as OK, WARNING, ERROR or CRITICAL.
        str
            A message to display about the WARNING, ERROR or CRITICAL result.
    """
    if isinstance(Validator, Exception):
        print(Validator, file=registry.logger)
        return (Status.CRITICAL,
                "Howso Data Quality Tool was not installed correctly. "
                "Please check installation.")
    validator = None
    try:
        if source_df is None:
            source_df, _ = generate_dataframe(client=registry.client, num_samples=150)

        orig_df = source_df.sample(frac=0.5)
        gen_df = source_df[~source_df.index.isin(orig_df.index)]
        features = infer_feature_attributes(orig_df)
        if not Validator:
            raise AssertionError('Howso Validator™ is not installed.')
        validator = Validator(orig_df, gen_df, features=features, verbose=-1)
        desirability = validator.run_metric("DescriptiveStatistics").desirability
        if desirability < desirability_threshold:
            return (Status.WARNING, f"Desirability did not exceed the threshold of {desirability_threshold:,.1f}.")
    except Exception:
        traceback.print_exc(file=registry.logger)
        return (Status.CRITICAL,
                "Could not complete operation. Check installation.")
    else:
        return (Status.OK, "")
    finally:
        if validator:
            for dataset in [validator.orig, validator.gen]:
                if isinstance(dataset, Trainee):
                    if id := getattr(dataset, "id", None):
                        try:
                            registry.client.release_trainee_resources(id)
                        except Exception:  # noqa: Deliberately broad
                            pass


def _attempt_train_date_feature(result_queue: multiprocessing.Queue):
    """
    Attempt to train a date feature to check for proper time zone support.

    Parameters
    ----------
    result_queue : A multiprocessing queue instance
        A queue to put the results.
    """
    client = HowsoClient()
    features = {'date': {'type': 'continous', 'date_time_format': '%Y-%m-%d'}}
    trainee_obj = Trainee(
        f"installation_verification check_tzdata_installed ({get_nonce()})",
        features=features,
        default_context_features='date',
        default_action_features='date',
        persistence='never'
    )
    trainee = client.create_trainee(trainee_obj)
    client.train(trainee_id=trainee.id, cases=[["2001-01-01"]], features=['date'])
    result_queue.put(client.get_num_training_cases(trainee.id))
    client.delete_trainee(trainee.id)


def check_tzdata_installed(*, registry: InstallationCheckRegistry):
    """
    Check for timezone support in host OS.

    The installation_verification module has already checked normal operations
    without dates. This check is to ensure that the host OS can support time-
    zone aware date-time handling. This is accomplished by merely training a
    model with a date feature.

    In some configurations, this may result in a SegFault, so, we need to
    isolate the critical part of this test into another process.

    Parameters
    ----------
    registry : InstallationCheckRegistry
        The InstallationCheckRegistry instance.
    """
    try:
        # If the host OS does not have timezone support, simply creating and
        # training on a date/time feature will SegFault. So, we do this in a
        # spawned process so we can detect this in the main thread.
        ctx = multiprocessing.get_context("spawn")
        result_queue = ctx.Queue(maxsize=-1)
        proc = ctx.Process(target=_attempt_train_date_feature,
                           args=(result_queue, ))
        proc.start()
        proc.join()
        result = result_queue.get(block=False)

        if result is None:
            # Nothing was put into the queue...
            raise Exception(
                "The check process failed before it could return a result.")
    except Exception:  # noqa: Deliberately broad
        traceback.print_exc(file=registry.logger)
        return (Status.CRITICAL,
                "Unable to work with date/times. Please ensure that the host "
                "OS has timezone support.")
    else:
        return (Status.OK, "")


def configure(registry: InstallationCheckRegistry):
    """
    Register the correct checks for the install environment.

    Parameters
    ----------
    registry : InstallationCheckRegistry
        The InstallationCheckRegistry instance.
    """
    registry.add_check(
        name="Howso Local: Timezone support",
        fn=check_tzdata_installed,
        client_required="HowsoDirectClient",
    )

    registry.add_check(
        name="Howso Client: Basic react",
        fn=partial(check_generate_dataframe, threshold=10.0),
        client_required="HowsoPlatformClient",
    )

    registry.add_check(
        name="Howso Local: Basic react",
        fn=partial(check_generate_dataframe, threshold=1.0),
        client_required="HowsoDirectClient",
    )

    registry.add_check(
        name="Howso Client: Network latency",
        fn=partial(check_latency, notice_threshold=25,
                   warning_threshold=20),
        client_required="HowsoPlatformClient",
    )

    registry.add_check(
        name="Howso Client: System performance",
        fn=partial(check_performance, num_samples=2_000,
                   notice_threshold=15.0, warning_threshold=20.0),
        client_required="HowsoPlatformClient",
    )

    registry.add_check(
        name="Howso Local: System performance",
        fn=partial(check_performance, num_samples=5_000,
                   notice_threshold=10.0, warning_threshold=20.0),
        client_required="HowsoDirectClient",
    )

    registry.add_check(
        name="Howso Local: Save model",
        fn=check_save,
        client_required="HowsoDirectClient",
    )

    registry.add_check(
        name="Howso Engine™: Basic operations",
        fn=check_engine_operation,
        client_required="AbstractHowsoClient",
        other_requirements=[engine],
    )

    registry.add_check(
        name="Howso Synthesizer™: Supported system locales",
        fn=check_locales_available,
        client_required="AbstractHowsoClient",
        other_requirements=[Synthesizer],
    )

    registry.add_check(
        name="Howso Synthesizer: Basic operations",
        fn=partial(check_synthesizer_create_delete),
        client_required="AbstractHowsoClient",
        other_requirements=[Synthesizer],
    )

    registry.add_check(
        name="Howso Synthesizer: Basic synthesis",
        fn=check_basic_synthesis,
        client_required="AbstractHowsoClient",
        other_requirements=[Synthesizer],
    )

    registry.add_check(
        name="Howso Validator Enterprise: Desirability",
        fn=partial(check_validator_enterprise_desirability, desirability_threshold=2.0),
        client_required="AbstractHowsoClient",
        other_requirements=[Synthesizer, Validator],
    )


def main():
    """Primary entry point."""
    print("[bold]Validating Howso™ Installation")
    registry = InstallationCheckRegistry()
    with warnings.catch_warnings():
        configure(registry)
        warnings.simplefilter("ignore")
        sys.exit(registry.run_checks())


if __name__ == "__main__":
    main()

howso/utilities/installation_verification.py
howsoai/howso-engine-py
"""
Internal utilities.

Notice: These are internal utilities and are not intended to be
        referenced directly.
"""
from collections import OrderedDict
from collections.abc import Iterable
from copy import deepcopy
import datetime
import decimal
from inspect import getfullargspec
import logging
import math
import random
import re
from typing import (
    Any, Dict, Generator, Iterable, List, Mapping, Optional, Tuple,
    TYPE_CHECKING, Union,
)
import unicodedata
import uuid
import warnings

from humanize import precisedelta
import numpy as np
import pandas as pd
from semantic_version import Version

logger = logging.getLogger(__name__)

if TYPE_CHECKING:
    from .monitors import ProgressTimer


def postprocess_trainee(trainee):
    """
    Post-process a trainee to update its data into the expected format.

    Should be used on trainee objects returned from the API.
    NOTE: Mutates the original trainee object.

    Parameters
    ----------
    trainee : Trainee
        The trainee instance.

    Returns
    -------
    Trainee
        The trainee instance.
    """
    trainee.features = postprocess_feature_attributes(trainee.features)
    return trainee


def preprocess_trainee(trainee):
    """
    Pre-process a trainee to update its data into the expected format.

    Should be used on trainee objects before sending to the API.
    Does not mutate the original trainee object.

    Parameters
    ----------
    trainee : Trainee
        The trainee instance.

    Returns
    -------
    Trainee
        Updated copy of the trainee instance.
    """
    trainee = deepcopy(trainee)
    trainee.features = preprocess_feature_attributes(trainee.features)
    return trainee


def deserialize_to_dataframe(
    data: Union[Iterable[Iterable[object]], Iterable[Dict[str, object]]],
    columns: Optional[Iterable[str]] = None,
    index: Optional[Iterable[Any]] = None
) -> pd.DataFrame:
    """
    Deserialize data into a DataFrame.

    Parameters
    ----------
    data : list of list of object or list of dict
        The data to deserialize.
    columns : list of str
        The column mapping. The order corresponds to how the data will
        be mapped to columns in the output. Ignored for list of dict where
        the dict key is the column name.
    index : list of Any
        The row index to use.

    Returns
    -------
    pandas.DataFrame
        The deserialized data as DataFrame.
    """
    if data is not None:
        if isinstance(data, dict) and all(x is None for x in data.values()):
            return pd.DataFrame([], columns=columns or data.keys())
        return pd.DataFrame(data, columns=columns, index=index)
    else:
        return pd.DataFrame([], columns=columns, index=index)


def get_features_from_data(
    data: Any, *,
    default_features: Optional[List[str]] = None,
    data_parameter: Optional[str] = 'cases',
    features_parameter: Optional[str] = 'features'
) -> List[str]:
    """
    Retrieve feature names from dataframe columns.

    Parameters
    ----------
    data : Any
        The data to inspect for feature names.
    default_features : list of str, optional
        Feature names to fallback to if unable to determine from DataFrame.
    data_parameter : str, optional
        The name of the data parameter to reference in the error message.
    features_parameter : str, optional
        The name of the parameter to require for features in the error message
        if features cannot be determined using the data.

    Returns
    -------
    list of str
        The feature names.

    Raises
    ------
    HowsoError
        When cases are not a DataFrame or when the DataFrame does not contain
        named columns.
    """
    # Import locally to prevent a circular import
    from howso.client.exceptions import HowsoError

    if isinstance(data, pd.DataFrame):
        if isinstance(data.columns, pd.RangeIndex):
            raise HowsoError(
                f"A `{features_parameter}` list is required when the "
                f"`{data_parameter}` DataFrame does not contain named "
                f"columns.")
        else:
            return data.columns.tolist()
    elif default_features is not None:
        return default_features
    else:
        raise HowsoError(
            f"A `{features_parameter}` list is required when "
            f"`{data_parameter}` are not provided as a DataFrame.")


def serialize_openapi_models(obj: Any, *, exclude_null: bool = False) -> Any:
    """
    Serialize OpenAPI client model instances.

    Parameters
    ----------
    obj : dict or list or object

    Returns
    -------
    list or dict or object
        The serialized model data.
    """
    if isinstance(obj, list):
        return [
            serialize_openapi_models(item, exclude_null=exclude_null)
            for item in obj
        ]
    if isinstance(obj, OrderedDict):
        # Use OrderedDict if input is an OrderedDict, for consistency
        result = OrderedDict()
        for k, v in obj.items():
            result[k] = serialize_openapi_models(v, exclude_null=exclude_null)
        return result
    if isinstance(obj, dict):
        return {
            k: serialize_openapi_models(v, exclude_null=exclude_null)
            for k, v in obj.items()
        }
    if hasattr(obj, 'to_dict'):
        args = getfullargspec(obj.to_dict).args
        if 'exclude_null' in args:
            return obj.to_dict(exclude_null=exclude_null)
        else:
            return obj.to_dict()
    return obj


def postprocess_feature_attributes(features):
    """
    Post-process feature attributes into the expected client format.

    Updates all date_time_format's to the original_format.python value,
    if it exists on the feature.

    Parameters
    ----------
    features : dict
        Dictionary of feature name to feature value.

    Returns
    -------
    dict or None
        The updated copy of features.
    """
    if features is None:
        return None

    # Serialize any OpenAPI models
    features = deepcopy(serialize_openapi_models(features))

    for feat in features.values():
        if feat is None:
            continue

        # Backwards compatibility for non-sensitive
        # TODO 15469 - Remove shim
        if 'non-sensitive' in feat:
            feat['non_sensitive'] = feat['non-sensitive']
            del feat['non-sensitive']

        # Replace any instances of 'date_time_format' with the original python
        # format if it is defined
        if 'date_time_format' in feat:
            try:
                if isinstance(feat['original_format']['python'], str):
                    # Backward compatibility shim for trainees created via
                    # API version <= 2.1.75, to translate string to dict format
                    feat['original_format']['python'] = {
                        'date_time_format': feat['original_format']['python']
                    }
                feat['date_time_format'] = (
                    feat['original_format']['python']['date_time_format'])
            except (TypeError, KeyError):
                pass

    return features


def preprocess_feature_attributes(features):
    """
    Pre-process feature attributes into the expected API format.

    Updates all date_time_format's for features by removing the Python-specific
    fractional '.%f' formatting used for high precision seconds. Keeps a copy
    of the original format in original_format.python.

    Parameters
    ----------
    features : dict
        Dictionary of feature name to feature value.

    Returns
    -------
    dict or None
        The updated copy of features.
    """
    if features is None:
        return None

    # Serialize any OpenAPI models
    features = deepcopy(serialize_openapi_models(features))

    regex = re.compile(r"%S.%f")
    for feat in features.values():
        if feat is None:
            continue

        # Shim to convert old "non-sensitive" properties to "non_sensitive"
        # TODO 15469 - Remove shim
        if 'non-sensitive' in feat:
            feat['non_sensitive'] = feat['non-sensitive']
            del feat['non-sensitive']
            warnings.warn(
                "The feature attribute 'non-sensitive' has been renamed to "
                "'non_sensitive', support for using the previous name will "
                "be removed in a future version.",
                DeprecationWarning
            )

        # Set decimal places to 0 when %S is in datetime format but %f is not.
        # This prevents core from returning microseconds
        try:
            if (
                '%S' in feat['date_time_format'] and
                '%f' not in feat['date_time_format'] and
                'decimal_places' not in feat
            ):
                feat['decimal_places'] = 0
        except (KeyError, TypeError, ValueError):
            pass

        # Replace any instances of %S.%f in 'date_time_format' with just %S
        # and store a copy of the original format in 'original_format.python'
        try:
            if regex.search(feat['date_time_format']):
                feat.setdefault('original_format', dict())
                feat['original_format'].setdefault('python', dict())
                feat['original_format']['python']['date_time_format'] = (
                    feat['date_time_format'])
                feat['date_time_format'] = regex.sub("%S",
                                                     feat['date_time_format'])
        except (KeyError, TypeError, ValueError):
            pass

    return features


def format_react_response(response, single_action=False):
    """
    Reformat the react response into a dict of action and explanation.

    Parameters
    ----------
    response : dict
        The raw react response object.
    single_action : bool, default False
        If response should be a single action value. (i.e. React into series)

    Returns
    -------
    dict
        A dict of two keys, action and explanation.
    """
    # Import locally to prevent a circular import
    from howso.utilities import replace_doublemax_with_infinity
    response = replace_doublemax_with_infinity(response)

    action_features = response['action_features'] or list()
    action_values = response['action_values']

    # Convert to format of a list of dicts of feature->value
    action = dict() if single_action else list()
    if (
        action_values is not None
        and len(action_values) > 0
        and len(action_features) > 0
    ):
        if single_action:
            action = dict(zip(action_features, action_values))
        else:
            action = [dict(zip(action_features, values)) for
                      values in action_values]

    # remove action_values from explanation to prevent output of dupe data
    del response['action_values']

    return {'action': action, 'explanation': response}


def accumulate_react_result(accumulated_result, result):
    """
    Accumulate the results from multiple reacts responses.

    Parameters
    ----------
    accumulated_result : dict
        Accumulated react responses. This object will be mutated in place.
    result : dict
        The react response object.

    Returns
    -------
    dict
         The updated accumulated_result dict.
    """
    for k, v in result.items():
        if accumulated_result.get(k) is None:
            if v is None:
                accumulated_result[k] = None
            else:
                accumulated_result[k] = []

        if k == 'action_features':
            # Only include action features once
            if not accumulated_result[k]:
                accumulated_result[k] = v
            continue

        if v is not None:
            accumulated_result[k].extend(v)

    return accumulated_result


def slugify(value, allow_unicode=False):
    """
    Slugify a value.

    Convert spaces or repeated dashes to single dashes. Remove characters that
    aren't alphanumerics, underscores, or hyphens. Convert to lowercase. Also
    strip leading and trailing whitespace, dashes, and underscores.

    Sourced from:
    https://github.com/django/django/blob/main/django/utils/text.py

    Parameters
    ----------
    value : Any
        The value to slugify.
    allow_unicode : bool, default False
        When False, converts to ASCII.

    Returns
    -------
    str
        The slugified version of the input value.
    """
    value = str(value)
    if allow_unicode:
        value = unicodedata.normalize("NFKC", value)
    else:
        value = (
            unicodedata.normalize("NFKD", value)
            .encode("ascii", "ignore")
            .decode("ascii")
        )
    value = re.sub(r"[^\w\s-]", "", value.lower())
    return re.sub(r"[-\s]+", "-", value).strip("-_")


def generate_cache_file_name(identifier, ext="txt"):
    """
    Generate a unique cache file name.

    Parameters
    ----------
    identifier : str
        An identifier to include in the filename.
    ext : str
        The file extension.

    Returns
    -------
    str
        The filename.
    """
    nonce = f'{random.randint(0, 16 ** 8):08x}'
    return f".howso_recent_cache_{slugify(identifier)}_{nonce}.{ext}"


def insufficient_generation_check(
    requested_num_cases: int,
    gen_num_cases: int,
    suppress_warning: bool = False
) -> bool:
    """
    Warn user about not generating sufficient number of cases.

    Parameters
    ----------
    requested_num_cases : int
        Number of cases requested by the user.
    gen_num_cases : int
        Number of cases actually generated.
    suppress_warning : bool, defaults to False
        (Optional) If True, warnings will be suppressed.
        By default, warnings will be displayed.

    Returns
    -------
    bool
        Returns `True` if requested number of cases is not equal to
        number of cases generated. Otherwise, returns `False`.
    """
    if gen_num_cases < requested_num_cases:
        if not suppress_warning:
            warnings.warn(
                f"The number of cases generated is less than number of "
                f"cases requested ({gen_num_cases} < {requested_num_cases}"
                f"). This might happen when `generate_new_cases` "
                f"parameter is set to 'always', and the data is heavily "
                f"constrained.", RuntimeWarning
            )
        return True
    return False


def sanitize_for_json(obj: Any):  # noqa: C901
    """
    Sanitizes data for JSON serialization.

    If obj is None, return None.
    If obj is str, int, float, bool, bytes, return directly.
    If obj is NaN or NaT, return None.
    If obj is datetime, date or time, convert to string in iso8601 format.
    If obj is timedelta, return float total seconds.
    If obj is iterable, sanitize each element to a list.
    If obj is mappable, sanitize each value to a dict.
    If obj is OpenAPI model, sanitize each attribute value and return as dict.
    If obj is NumPy data type, return as related primitive type.
    If obj is any other type, raise ValueError.

    Parameters
    ----------
    obj : Any
        The data to serialize.

    Returns
    -------
    Any
        The sanitized data.

    Raises
    ------
    TypeError
        When obj is of an unsupported type.
    """
    if obj is None:
        return None

    # Check for numpy data types and cast them appropriately before
    # serialization.
    if type(obj).__module__ == np.__name__:
        if isinstance(obj, np.ndarray):
            return [sanitize_for_json(sub_obj) for sub_obj in obj]
        elif isinstance(obj, np.datetime64):
            if np.isnat(obj):
                # Auto serialize NaT to None
                return None
            else:
                obj = obj.astype(datetime)
        elif issubclass(type(obj), (np.integer, int)):
            obj = int(obj)
        elif issubclass(type(obj), (np.floating, float)):
            obj = float(obj)
        elif issubclass(type(obj), str):
            obj = str(obj)
        elif isinstance(obj, bool):
            obj = bool(obj)
        else:
            # If it's not an int, float, array or bool, assume it is
            # meant to be passed as a string as there are no
            # byte/bytes data types supported by the core.
            logger.warning(
                f"Unknown numpy datatype {type(obj)} encountered during "
                f"serialization. Casting to a string.")
            obj = str(obj)

    # Serialize known types
    if isinstance(obj, (str, int, bool)):
        return obj
    elif isinstance(obj, bytes):
        logger.warning("Bytes data encountered during serialization. Casting "
                       "to a string.")
        return str(obj)
    elif isinstance(obj, float):
        if np.isnan(obj):
            # Auto serialize NaN to None
            return None
        return obj
    elif (
        isinstance(obj, Iterable) and
        not isinstance(obj, (str, bytes, Mapping))
    ):
        return [sanitize_for_json(sub_obj) for sub_obj in obj]
    elif isinstance(obj, (datetime.datetime, datetime.date, datetime.time)):
        if pd.isnull(obj):
            return None
        # Format datetime objects
        return obj.isoformat()
    elif isinstance(obj, datetime.timedelta):
        # Convert time deltas to total seconds
        return obj.total_seconds()
    elif isinstance(obj, decimal.Decimal):
        if obj.is_nan():
            # Convert NaNs to None
            return None
        # FIXME: Core does not yet fully support stringified/decimal numbers,
        #  so we convert to a float here instead.
        return float(obj)
    elif isinstance(obj, uuid.UUID):
        return str(obj)

    # Serialize objects to dict
    if isinstance(obj, Mapping):
        obj_dict = obj
    elif hasattr(obj, 'openapi_types') and hasattr(obj, 'attribute_map'):
        # Convert openapi model to dict using the attribute mapping,
        # excluding values which are None
        obj_dict = {obj.attribute_map[attr]: getattr(obj, attr)
                    for attr, _ in obj.openapi_types.items()
                    if getattr(obj, attr) is not None}
    else:
        try:
            # In some cases a pandas NAType could be present, check for it as
            # a last resort before raising a TypeError
            if pd.isnull(obj):
                return None
        except Exception:  # noqa: Deliberately broad
            # Failed to check value is null, must not be null
            pass
        # Unhandled type, raise
        raise TypeError(f"Object of type {type(obj)} is not serializable")

    return {key: sanitize_for_json(val) for key, val in obj_dict.items()}


def readable_timedelta(delta: datetime.timedelta, *,
                       microsecond_places: int = 2,
                       precision: Union[bool, int] = True,
                       ) -> str:
    """
    Format timedelta to a readable string.

    Example output: 1 hour 12 minutes and 9 seconds

    Parameters
    ----------
    delta : datetime.timedelta
        The time delta to format.
    microsecond_places : int, default 2
        How many microsecond decimal places to include when formatting seconds.
    precision : bool or int, default True
        When True, all time places will be included. Otherwise, only the first
        time place greater than 0 will be included. If specified with an
        integer, includes up to that number of time places.

    Returns
    -------
    str
        The formatted time delta.
    """
    abs_delta = abs(delta)
    year, remainder_days = divmod(abs_delta.days, 365)
    month, day = divmod(remainder_days, 30)
    hour, remainder_seconds = divmod(abs_delta.seconds, 3600)
    minute, second = divmod(remainder_seconds, 60)
    microseconds = round(abs_delta.microseconds / 1_000_000, microsecond_places)
    periods = {'years': year, 'months': month, 'days': day, 'hours': hour,
               'minutes': minute, 'seconds': second}

    parts = []
    decimal_format = '%0.0f'
    for period in ['years', 'months', 'days', 'hours', 'minutes', 'seconds']:
        value = periods[period]
        if period == 'seconds' and microseconds > 0:
            value += microseconds
            decimal_format = f'%0.{microsecond_places}f'
        if value > 0:
            parts.append(period)
            if isinstance(precision, bool) and not precision:
                break
            elif len(parts) == precision:
                break

    if parts:
        minimum_unit = parts[-1]
    else:
        minimum_unit = 'seconds'
    return precisedelta(delta, minimum_unit=minimum_unit, format=decimal_format)


class BatchScalingManager:
    """
    Manages scaling batching operations.

    Parameters
    ----------
    starting_size : int
        The requested starting batch size.
    progress_monitor : ProgressTimer
        A progress timer instance to use for scaling.
    """

    # Threshold by which batch sizes will be increased/decreased until
    # request-response time falls between these two times
    time_threshold: Tuple[datetime.timedelta, datetime.timedelta] = (
        datetime.timedelta(seconds=60), datetime.timedelta(seconds=75))
    # The batch size min and max (respectively)
    size_limits: Tuple[int, Optional[int]] = (1, None)
    # The rate at which batches are scaled up and down (respectively)
    # See: https://en.wikipedia.org/wiki/Golden_ratio
    size_multiplier: Tuple[float, float] = (1.618, 0.809)

    def __init__(self, starting_size: int, progress_monitor: "ProgressTimer"):
        """Initialize a new BatchScalingManager instance."""
        self.starting_size = starting_size
        self.progress = progress_monitor

    def gen_batch_size(self) -> Generator[int, Optional[datetime.timedelta],
                                          None]:
        """
        Returns a generator to get the next batch size.

        When using "send" progress updating must be done manually and the
        last tick duration should be provided as the parameter to "send". When
        not using "send" progress updating will happen automatically.
        """
        if not self.progress.has_started:
            raise ValueError("Batching has not yet started")

        batch_size = self.starting_size
        while not self.progress.has_ended and not self.progress.is_complete:
            batch_size = self.clamp(batch_size, self.progress.current_tick,
                                    self.progress.total_ticks)
            tick_duration = yield batch_size
            if tick_duration is None:
                # If send is not used, automatically update progress
                tick_duration = self.progress.tick_duration
                self.progress.update(batch_size)
            batch_size = self.scale(batch_size, tick_duration)
        return None

    def scale(self, batch_size: int, batch_duration: datetime.timedelta) -> int:
        """
        Scale batch size based on duration of the batch.

        Parameters
        ----------
        batch_size : int
            The current batch size.
        batch_duration : datetime.timedelta
            The time the last batch took to complete.

        Returns
        -------
        int
            The new batch size.
        """
        if batch_duration <= self.time_threshold[0]:
            # If took less than threshold, increase batch size
            batch_size = math.ceil(batch_size * self.size_multiplier[0])
        elif batch_duration > self.time_threshold[1]:
            # If took longer than threshold, lower batch size
            if self.size_multiplier[1] < 1:
                batch_size = math.floor(batch_size * self.size_multiplier[1])
            else:
                batch_size = math.floor(batch_size / self.size_multiplier[1])
        return batch_size

    def clamp(self, batch_size: int, batch_offset: int, total: int) -> int:
        """
        Clamp batch size between min/max allowed value.

        Parameters
        ----------
        batch_size : int
            The current batch size.
        batch_offset : int
            The current batch offset.
        total : int
            The total number of the items being batched.

        Returns
        -------
        int
            The new batch size.
        """
        # Clamp batch size to the minimum requested batch size, but
        # ensure it does not exceed total number of items batched
        batch_size = min(max(batch_size, self.size_limits[0]),
                         total - batch_offset)
        if self.size_limits[1]:
            # Limit batch size to maximum value
            batch_size = min(batch_size, self.size_limits[1])
        return batch_size


def show_core_warnings(core_warnings):
    """Warns the user for each warning returned from the core."""
    # Import here to avoid circular import
    from ..client.exceptions import HowsoWarning

    if isinstance(core_warnings, Iterable):
        for w in core_warnings:
            if warning := w.get("detail"):
                warnings.warn(warning, category=HowsoWarning)


def to_pandas_datetime_format(f):
    """
    Normalize the pandas datetime format.

    Checks if format is an ISO8601 like format. If so and pandas version is
    2.0.0 or greater, return "ISO8601"

    Parameters
    ----------
    f : str
        The format string.

    Returns
    -------
    str
        The normalized format.
    """
    # Prevent circular import
    from .utilities import date_format_is_iso
    if date_format_is_iso(f):
        try:
            pd_ver = Version(pd.__version__)
            if pd_ver.major is not None and pd_ver.major >= 2:
                return "ISO8601"
        except Exception:  # noqa: Deliberately broad
            # Failed to check pandas version
            pass
    return f


class IgnoreWarnings:
    """
    Simple context manager to ignore Warnings.

    Parameters
    ----------
    warning_types : Warning or Iterable of Warnings
        The warning classes to ignore.
    """

    def __init__(
        self,
        warning_types: Union[Warning, Iterable[Warning]]
    ):
        """Initialize a new `catch_warnings` instance."""
        self._catch_warnings = warnings.catch_warnings()
        self._warning_types = warning_types

        if not isinstance(self._warning_types, Iterable):
            self._warning_types = [self._warning_types]
        for warning_type in self._warning_types:
            self._check_warning_class(warning_type)

    @staticmethod
    def _check_warning_class(warning_type):
        """Check correct warning type."""
        if not issubclass(warning_type, Warning):
            warnings.warn(
                f"{warning_type} is not a valid subclass of `Warning`. "
                "Warnings will not be ignored."
            )

    def __enter__(self):
        """Context entrance."""
        # Enters the  `catch_warnings` instance.
        self._catch_warnings.__enter__()
        for warning_type in self._warning_types:
            warnings.filterwarnings("ignore", category=warning_type)
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        """Context exit."""
        self._catch_warnings.__exit__(exc_type, exc_value, traceback)
        return False

howso/utilities/internals.py
howsoai/howso-engine-py
"""
A convenient wrapper for pysimdjson.

json_wrapper module provides an interface either, the standard module `json`
built-into Python or the simdjson "drop-in replacement API", depending one what
is installed.

The `pysimdjson` package must be installed separately.
"""
import json
from typing import Any

try:
    import simdjson  # noqa
except ImportError:
    simdjson = None


def detect_encoding(b):
    """
    Detect encoring.

    Always pass-thru to built-in `json` module for detect_encoding().
    """
    return json.detect_encoding(b)


def dump(*args, **kwargs):
    """
    Dump object to JSON.

    Always pass-thru to built-in `json` module for dump().
    """
    kwargs.setdefault('allow_nan', False)
    return json.dump(*args, **kwargs)


def dumps(*args, **kwargs):
    """
    Dumpy object to JSON string.

    Always pass-thru to built-in `json` module for dumps().
    """
    kwargs.setdefault('allow_nan', False)
    return json.dumps(*args, **kwargs)


def load(fp, *, object_hook=None, **kwargs) -> Any:
    """
    Use the fastest available `load` for JSON based on kwargs given.

    For simple JSON loading, implement via simdjson, which offers ~2X
    performance vs. Python's native `json.load()`. If any keyword parameters
    are supplied except `object_hook` or simdjson is not available then default
    to the native implementation.
    """
    if simdjson and len(kwargs) == 0:
        return simdjson.load(fp, object_hook=object_hook)
    else:
        return json.load(fp, object_hook=object_hook, **kwargs)


def loads(s, *, object_hook=None, **kwargs) -> Any:
    """
    Use the fastest available `loads` for JSON based on kwargs given.

    For simple JSON loading, implement via simdjson, which offers ~2X
    performance vs. Python's native `json.loads()`. If any keyword parameters
    are supplied except `object_hook` or simdjson is not available then default
    to the native implementation.
    """
    if simdjson and len(kwargs) == 0:
        return simdjson.loads(s, object_hook=object_hook)
    else:
        return json.loads(s, object_hook=object_hook, **kwargs)

howso/utilities/json_wrapper.py
howsoai/howso-engine-py
import locale
import sys


def get_default_locale(envvars=('LC_ALL', 'LC_CTYPE', 'LANG', 'LANGUAGE')):
    """
    Implement a copy of the Python method `locale.getdefaultlocale()`.

    The Python method has been deprecated in Python 3.11 in favor of
    `locale.getlocales()`. Unforunately, on Windows, `locale.getlocales()`
    which appears have issues with some variants of Windows.

    By redefining it here, we can avoid deprecation warnings and have a
    suitable abstraction point for a more permanent, future solution.

    -- original docstring follows --

    Tries to determine the default locale settings and returns them as tuple
    (language code, encoding).

    According to POSIX, a program which has not called setlocale(LC_ALL, "")
    runs using the portable 'C' locale. Calling setlocale(LC_ALL, "") lets it
    use the default locale as defined by the LANG variable. Since we don't want
    to interfere with the current locale setting we thus emulate the behavior
    in the way described above.

    To maintain compatibility with other platforms, not only the LANG variable
    is tested, but a list of variables given as envvars parameter. The first
    found to be defined will be used. envvars defaults to the search path used
    in GNU gettext; it must always contain the variable name 'LANG'.

    Except for the code 'C', the language code corresponds to RFC 1766. code
    and encoding can be None in case the values cannot be determined.
    """
    try:
        # check if it's supported by the _locale module
        import _locale  # noqa
        code, encoding = _locale._getdefaultlocale()
    except (ImportError, AttributeError):
        pass
    else:
        # make sure the code/encoding values are valid
        if sys.platform == "win32" and code and code[:2] == "0x":
            # map windows language identifier to language name
            code = locale.windows_locale.get(int(code, 0))
        # ...add other platform-specific processing here, if
        # necessary...
        return code, encoding

    # fall back on POSIX behaviour
    import os
    lookup = os.environ.get
    for variable in envvars:
        localename = lookup(variable, None)
        if localename:
            if variable == 'LANGUAGE':
                localename = localename.split(':')[0]
            break
    else:
        localename = 'C'
    return locale._parse_localename(localename)

howso/utilities/locale.py
howsoai/howso-engine-py
from datetime import datetime, timedelta
from typing import Union

__all__ = [
    "ProgressTimer",
    "Timer",
]


class Timer:
    """
    Simple context manager to capture run duration of the inner context.

    Usage::

        with Timer() as my_timer:
            # perform time-consuming task here...
        print(f"The task took {my_timer.duration}."

    Results in::

        "The task took 1:30:10.454419"
    """

    def __init__(self):
        """Initialize a new Timer instance."""
        self.start_time = None
        self.end_time = None

    def start(self) -> "Timer":
        """Start the timer."""
        self.reset()
        self.start_time = datetime.now()
        return self

    def end(self) -> None:
        """End the timer."""
        self.end_time = datetime.now()

    def reset(self) -> None:
        """Reset the timer."""
        self.start_time = None
        self.end_time = None

    @property
    def has_started(self) -> bool:
        """If the timer has started."""
        return self.start_time is not None

    @property
    def has_ended(self) -> bool:
        """If the timer has ended."""
        return self.end_time is not None

    @property
    def duration(self) -> Union[timedelta, None]:
        """
        The total computed duration of the timer.

        Returns
        -------
        timedelta or None
            The total duration of the timer. When the timer has not yet ended,
            the duration between now and when the timer started will be
            returned. If the timer has not yet started, returns None.
        """
        if self.start_time is None:
            return None
        if self.end_time is None:
            return datetime.now() - self.start_time
        return self.end_time - self.start_time

    @property
    def seconds(self) -> Union[float, None]:
        """The total seconds representing the duration of timer instance."""
        if duration := self.duration:
            return duration.total_seconds()
        return None

    def __enter__(self):
        """Context entrance."""
        return self.start()

    def __exit__(self, *args):
        """Context exit."""
        self.end()


class ProgressTimer(Timer):
    """
    Monitor progress of a task.

    Parameters
    ----------
    total_ticks : int, default 100
        The total number of ticks in the progress meter.
    start_tick : int, default 0
        The starting tick.
    """

    def __init__(self, total_ticks: int = 100, *, start_tick: int = 0):
        """Initialize a new ProgressTimer instance."""
        super().__init__()
        self.last_tick_time = None
        self.total_ticks = total_ticks
        self.current_tick = start_tick
        self.update_count = 0
        self._start_tick = start_tick

    @property
    def time_remaining(self) -> timedelta:
        """
        The estimated time remaining.

        Returns
        -------
        timedelta
            The time estimated to be remaining.

        Raises
        ------
        ValueError
            If timer not yet started.
        """
        if not self.has_started:
            raise ValueError("Progress timer not started")
        elapsed_time = datetime.now() - self.start_time
        return ((self.total_ticks * elapsed_time /
                 max(self.current_tick, 1)) - elapsed_time)

    @property
    def tick_duration(self) -> Union[timedelta, None]:
        """
        The duration since the last tick.

        Returns
        -------
        timedelta or None
            The duration since the last tick, or None if not yet started.
        """
        if not self.last_tick_time:
            return None
        return datetime.now() - self.last_tick_time

    @property
    def is_complete(self) -> bool:
        """If progress has reached completion."""
        return self.current_tick == self.total_ticks

    @property
    def progress(self) -> float:
        """The current progress percentage."""
        return self.current_tick / self.total_ticks

    def update(self, ticks: int = 1) -> None:
        """
        Update the progress by given ticks.

        Parameters
        ----------
        ticks : int, default 1
            The number of ticks to increment/decrement by.
        """
        self.current_tick += ticks
        if self.current_tick > self.total_ticks:
            self.current_tick = self.total_ticks
        elif self.current_tick < 0:
            self.current_tick = 0
        self.last_tick_time = datetime.now()
        self.update_count += 1

    def reset(self) -> None:
        """Reset the progress timer."""
        super().reset()
        self.last_tick_time = None
        self.update_count = 0
        self.current_tick = self._start_tick

    def start(self) -> "ProgressTimer":
        """Start the progress timer."""
        super().start()
        self.last_tick_time = self.start_time
        return self

howso/utilities/monitors.py
howsoai/howso-engine-py
from ctypes import (
    byref, c_int32, c_int64, c_uint, cast,
    CDLL, create_string_buffer, POINTER,
)
from ctypes.util import find_library
import sys
from typing import Union


_LIBC = None


class PlatformError(Exception):
    """Wrong platform."""


class CLibError(Exception):
    """Problem instantiating the C Library."""


def sysctl_by_name(name: Union[str, bytes], output_type: str = 'raw',
                   encoding: str = "UTF-8") -> Union[bytes, int, str]:
    """
    Call `sysclt` with the provided key and return the result.

    Maintains a single connection to the C library per process.

    Parameters
    ----------
    name : str or bytes
        The key to use with `sysctl`. If given as `str`, will be decoded using
        the given `encoding`.
    output_type : str, default 'raw'
        Adapt the response into the given type: `int`, `str` and `raw` are
        currently supported.
    encoding : str, default "UTF-8"
        The locale to encode/decode strings from/to.

    Returns
    -------
    bytes, int or str
        The result of the `sysctl` call, possibly modified by the
        given `output_type`.

    Raises
    ------
    PlatformError
        If an attempt to call this method on a non-Posix system
    CLibError
        If unable to instantiate the C library.
    """
    global _LIBC

    if sys.platform == "win32":
        raise PlatformError('Unable to use `sysctl` on Win32 platforms.')

    if not _LIBC:
        try:
            _LIBC = CDLL(find_library("c"))
        except Exception as e:  # Deliberately broad
            raise CLibError('Unable to instantiate the C library.') from e

    size = c_uint(0)

    if isinstance(name, str):
        name = bytes(name, encoding=encoding)

    # Call to get buffer size...
    _LIBC.sysctlbyname(name, None, byref(size), None, 0)
    # Create the buffer
    buf = create_string_buffer(size.value)
    # Call with sized buffer
    _LIBC.sysctlbyname(name, buf, byref(size), None, 0)

    if output_type == 'int':
        if size.value == 4:
            return cast(buf, POINTER(c_int32)).contents.value
        if size.value == 8:
            return cast(buf, POINTER(c_int64)).contents.value
    elif output_type == 'str':
        return buf.value.decode(encoding=encoding)
    else:  # return_type == 'raw'
        return buf.raw

howso/utilities/posix.py
howsoai/howso-engine-py
from secrets import randbits


def get_random_seed() -> int:
    """
    Simply return a random seed.

    Provide a cryptographically secure, 64-bit random integer using the
    `secrets` module, which will use hardware-based (when available) entropy to
    generate random states.

    Supplied here for use throughout Howso client software.
    """
    return randbits(64)

howso/utilities/random.py
howsoai/howso-engine-py
"""Utilities to aide in testing `howso-engine`."""
import os
from typing import Callable, Union
from unittest.mock import patch

from howso.client import AbstractHowsoClient, HowsoClient
from howso.direct import HowsoDirectClient


def get_test_options():
    """
    Simply parses the ENV variable 'TEST_OPTIONS' into a list, if possible
    and returns it. This will be used with `pytest.skipif` to conditionally
    test some additional tests.

    Example:
        >>> from . import get_test_options
        >>> ...
        >>> @pytest.mark.skipif('FOO' not in get_test_options, reason='FOO not in ENV')  # noqa
        >>> def test_bar(...):
        >>>     ...

    Returns
    -------
    list[str]
    """
    try:
        options = os.getenv('TEST_OPTIONS').split(',')
    except (AttributeError, ValueError):
        options = []
    return options


def get_configurationless_test_client(
    client_class: Union[AbstractHowsoClient, Callable] = HowsoDirectClient,
    **kwargs
) -> HowsoClient:
    """
    Return a client for use within testing.

    By default, this will instantiate a default HowsoDirectClient with no
    passed configuration nor any of the special environment variables. If the
    TEST_OPTIONS environment variable contains "USE_HOWSO_CONFIG", then the
    client will resume normal behavior and use a configuration found in the
    normal places or as defined by the `HOWSO_CONFIG` environment variable.

    Parameters
    ----------
    client_class : AbstractHowsoClient or Callable
        A subclass of AbstractHowsoClient or the HowsoClient callable.

    Returns
    -------
    AbstractHowsoClient
        An instance of a subclass of AbstractHowsoClient.
    """
    if "USE_HOWSO_CONFIG" in get_test_options():
        return client_class(**kwargs)
    else:
        # Ignore any locally defined config (howso.yml) files.
        names_to_remove = ('XDG_CONFIG_HOME', 'HOWSO_CONFIG')
        # And the HOWSO_CONFIG and
        modified_environ = {
            k: v for k, v in os.environ.items()
            if k not in names_to_remove
        }
        # Once we stop supporting 3.9 and lower, we can parenthesize these.
        # See: https://docs.python.org/3.10/whatsnew/3.10.html#parenthesized-context-managers
        with patch('howso.client.client.get_configuration_path') as mocked_fn, \
             patch.dict('os.environ', modified_environ, clear=True):
            mocked_fn.return_value = None
            return client_class(**kwargs)

howso/utilities/testing.py
howsoai/howso-engine-py
import subprocess
import warnings


def has_locales(locales):
    """
    This is for the `pytest.mark.skipif` decorator. Skip the test of the
    required locales are not installed in this system.

    Background
    ----------
    It seems there's no reliable way to get a list of available locales
    directly in Python. `locale.locale_alias` returns /only/ aliases to
    existing locales, but not the original locales themselves.

    Parameters
    ----------
    locales : iterable[str]
        Iterable of language_codes E.g.: ['en_US.utf8', 'fr_CA.utf8', ... ]

    Returns
    -------
    bool :
        True if the given locales are available.
    """
    try:
        out = subprocess.run(['locale', '-a'], stdout=subprocess.PIPE).stdout
        results = ''
        for encoding in ['utf8', 'ISO8859-1', 'Windows-1252']:
            try:
                results = out.decode(encoding)
                break
            except UnicodeDecodeError:
                continue
        else:
            raise Exception('Could not decode subprocess output')
        locale_list = results.rstrip('\n').splitlines()
        return all(locale_item in locale_list for locale_item in locales)
    except Exception:  # noqa: Deliberately broad
        warnings.warn('Failed to check system locale')
        return False

howso/utilities/tests/__init__.py
howsoai/howso-engine-py
import datetime
import decimal
import json
import locale
import warnings


from howso.utilities import infer_feature_attributes
from howso.utilities.features import FeatureSerializer, FeatureType
from howso.utilities.internals import sanitize_for_json
from howso.utilities.utilities import LocaleOverride
import numpy as np
import pandas as pd
from pandas.core.dtypes.common import (
    is_datetime64_dtype,
    is_string_dtype,
)
import pytest
import pytz

from . import has_locales


@pytest.mark.parametrize('data_format', ['pandas', 'numpy'])
@pytest.mark.parametrize('data, original_type', [
    ([datetime.datetime(2020, 10, 10, 10, 5,
                        tzinfo=pytz.timezone('US/Eastern'))],
     {'data_type': str(FeatureType.DATETIME), 'timezone': 'US/Eastern'}),
    ([datetime.datetime(2020, 10, 10, 10, 5, tzinfo=pytz.FixedOffset(-300))],
     {'data_type': str(FeatureType.DATETIME)}),
    ([datetime.datetime.fromisoformat("2022-01-01")],
     {'data_type': str(FeatureType.DATETIME)}),
    ([datetime.datetime.fromisoformat("2022-01-01T10:00:00-05:00")],
     {'data_type': str(FeatureType.DATETIME)}),
    ([datetime.datetime(2022, 1, 1, 10, 0, 0, tzinfo=pytz.timezone("EST"))],
     {'data_type': str(FeatureType.DATETIME), 'timezone': 'EST'}),
    ([datetime.date.fromisoformat("2022-01-01")],
     {'data_type': str(FeatureType.DATE)}),
    ([datetime.timedelta(days=1, seconds=60)],
     {'data_type': str(FeatureType.TIMEDELTA), 'unit': 'seconds'}),
    ([datetime.timedelta(microseconds=1000)],
     {'data_type': str(FeatureType.TIMEDELTA), 'unit': 'seconds'}),
    ([np.timedelta64(5, 'Y')],
     {'data_type': str(FeatureType.TIMEDELTA), 'unit': 'seconds'}),
    ([np.timedelta64(3, 'M')],
     {'data_type': str(FeatureType.TIMEDELTA), 'unit': 'seconds'}),
    ([np.timedelta64(50, 'W')],
     {'data_type': str(FeatureType.TIMEDELTA), 'unit': 'seconds'}),
    ([np.timedelta64(10, 'D')],
     {'data_type': str(FeatureType.TIMEDELTA), 'unit': 'seconds'}),
    ([np.timedelta64(91, 'D')],
     {'data_type': str(FeatureType.TIMEDELTA), 'unit': 'seconds'}),
    ([np.timedelta64(10, 'h'), np.timedelta64(10, 'm')],
     {'data_type': str(FeatureType.TIMEDELTA), 'unit': 'seconds'}),
    ([np.timedelta64(55, 'm')],
     {'data_type': str(FeatureType.TIMEDELTA), 'unit': 'seconds'}),
    ([np.timedelta64(60, 's')],
     {'data_type': str(FeatureType.TIMEDELTA), 'unit': 'seconds'}),
    ([np.timedelta64(1000, 'ms')],
     {'data_type': str(FeatureType.TIMEDELTA), 'unit': 'seconds'}),
    ([np.timedelta64(1000, 'us')],
     {'data_type': str(FeatureType.TIMEDELTA), 'unit': 'seconds'}),
    ([np.timedelta64(1000, 'ns')],
     {'data_type': str(FeatureType.TIMEDELTA), 'unit': 'seconds'}),
    ([100, 100000000000], {'data_type': str(FeatureType.INTEGER), 'size': 8}),
    ([np.int8(125)], {'data_type': str(FeatureType.INTEGER), 'size': 1}),
    ([np.int16(1000)], {'data_type': str(FeatureType.INTEGER), 'size': 2}),
    ([np.int32(1000)], {'data_type': str(FeatureType.INTEGER), 'size': 4}),
    ([np.int64(1000)], {'data_type': str(FeatureType.INTEGER), 'size': 8}),
    ([5, None], {'data_type': str(FeatureType.NUMERIC), 'size': 8}),
    ([5.5, None], {'data_type': str(FeatureType.NUMERIC), 'size': 8}),
    ([5.5, 1000000.0], {'data_type': str(FeatureType.NUMERIC), 'size': 8}),
    ([np.float16(1.5)], {'data_type': str(FeatureType.NUMERIC), 'size': 2}),
    ([np.float32(10.5)], {'data_type': str(FeatureType.NUMERIC), 'size': 4}),
    ([np.float64(100.5)], {'data_type': str(FeatureType.NUMERIC), 'size': 8}),
    ([decimal.Decimal('1.1')], {'data_type': str(FeatureType.NUMERIC),
                                'format': 'decimal'}),
    ([None, 'test'],
     {'data_type': str(FeatureType.STRING)}),
    ([True, False], {'data_type': str(FeatureType.BOOLEAN)}),
    ([None, None], {'data_type': str(FeatureType.UNKNOWN)})
])
def test_feature_deserialization(data_format, data, original_type):
    """
    Test case serialization.

    Tests serialization of data from DataFrame/Numpy to JSON then back again, results
    in original data types.
    """
    # No warnings should be raised
    with warnings.catch_warnings():
        warnings.simplefilter(action="ignore", category=FutureWarning)
        warnings.simplefilter("error", append=True)
        columns = ['a']
        df = pd.DataFrame(pd.Series(data, name='a'))
        features = infer_feature_attributes(df)
        if data_format == "numpy":
            df_numpy = np.array(data)
            df_numpy = np.array([[value] for value in df_numpy])
            cases = FeatureSerializer.serialize(df_numpy, columns, features)
        else:
            cases = FeatureSerializer.serialize(df, columns, features)
        json_data = json.dumps(sanitize_for_json(cases))
        new_df = FeatureSerializer.deserialize(
            json.loads(json_data), columns, features)

        assert 'original_type' in features['a']
        assert features['a']['original_type'] == original_type
        difference = df.compare(new_df)
        assert difference.empty


@pytest.mark.parametrize('data, expected_dtype, original_type', [
    # Integers
    ([[1], [2]], 'int8',
     {'data_type': str(FeatureType.INTEGER), 'size': 1}),
    ([[1], [2]], 'uint8',
     {'data_type': str(FeatureType.INTEGER), 'size': 1, 'unsigned': True}),
    ([[1], [None], [2]], 'Int64',
     {'data_type': str(FeatureType.INTEGER), 'size': 8}),
    ([[1], [float('nan')], [2]], 'Int16',
     {'data_type': str(FeatureType.INTEGER), 'size': 2}),
    ([[10.5], [None], [5.0]], 'Int64',
     {'data_type': str(FeatureType.INTEGER), 'size': 8}),
    ([[10.5], [float('nan')], [5.0]], 'Int32',
     {'data_type': str(FeatureType.INTEGER), 'size': 4}),
    ([["1.2"], [None], ["3.3"]], 'Int64',
     {'data_type': str(FeatureType.INTEGER), 'size': 8}),
    ([["1.2"], ["1"], ["3.3"]], 'int64',
     {'data_type': str(FeatureType.INTEGER), 'size': 8}),
    ([["1.2"], [float('nan')], ["3.3"]], 'Int32',
     {'data_type': str(FeatureType.INTEGER), 'size': 4}),
    ([["1.2"], [float('nan')], ["3.3"]], 'UInt32',
     {'data_type': str(FeatureType.INTEGER), 'size': 4, 'unsigned': True}),
    ([["1"], [None], ["3"]], 'Int64',
     {'data_type': str(FeatureType.INTEGER), 'size': 8}),
    ([["1"], [float('nan')], ["3"]], 'Int32',
     {'data_type': str(FeatureType.INTEGER), 'size': 4}),
    # Floats
    ([[1], [2], [3]], 'float64',
     {'data_type': str(FeatureType.NUMERIC), 'size': 8}),
    ([[1.5], [float('nan')], [None], [float('inf')], [3.0]], 'float64',
     {'data_type': str(FeatureType.NUMERIC), 'size': 8}),
    ([["1"], ['nan'], ["3"]], 'float32',
     {'data_type': str(FeatureType.NUMERIC), 'size': 4}),
    ([["1.5"], [float('nan')], [float('inf')], ["3.3"]], 'float128',
     {'data_type': str(FeatureType.NUMERIC), 'size': 16}),
    ([["1.5"], ['NaN'], [None], ["3.3"]], 'object',
     {'data_type': str(FeatureType.NUMERIC), 'format': 'decimal'}),
])
def test_feature_deserialization_number_conversions(data, original_type,
                                                    expected_dtype):
    if expected_dtype in ['longdouble', 'float128', 'float256']:
        if not hasattr(np, expected_dtype):
            pytest.skip('Unsupported platform')

    # No warnings should be raised
    with warnings.catch_warnings():
        warnings.simplefilter("error")
        features = {'a': {'type': 'continuous', 'original_type': original_type}}
        df = FeatureSerializer.deserialize(data, features.keys(), features)
        assert not df.empty
        dtype = df['a'].dtype
        if 'size' in original_type:
            assert dtype.itemsize == original_type['size']
        assert dtype == expected_dtype


@pytest.mark.parametrize('data_format', ['pandas', 'numpy'])
@pytest.mark.parametrize('data, dt_format, valid_dtype, expected_data', [
    (
        [datetime.datetime(2020, 10, 25, 10, 5,
                           tzinfo=pytz.timezone('US/Eastern'))],
        '%Y-%m-%dT%H:%M:%S%z', lambda dtype: isinstance(dtype, pd.DatetimeTZDtype),
        [datetime.datetime(2020, 10, 25, 10, 5,
                           tzinfo=pytz.timezone('US/Eastern'))],
    ),
    (
        [datetime.datetime(2020, 10, 25, 10, 5,
                           tzinfo=pytz.timezone('US/Eastern'))],
        '%Y-%m-%dT%H:%M:%S', lambda dtype: isinstance(dtype, pd.DatetimeTZDtype),
        [datetime.datetime(2020, 10, 25, 10, 5,
                           tzinfo=pytz.timezone('US/Eastern'))],
    ),
    (
        [datetime.datetime(2020, 10, 25, 10, 5, tzinfo=pytz.timezone('UTC'))],
        '%Y-%m-%dT%H:%M:%S', lambda dtype: isinstance(dtype, pd.DatetimeTZDtype),
        [datetime.datetime(2020, 10, 25, 10, 5, tzinfo=pytz.timezone('UTC'))],
    ),
    (
        [datetime.datetime(2020, 10, 25, 10, 5, tzinfo=pytz.FixedOffset(-300))],
        '%Y-%m-%dT%H:%M:%S%z', lambda dtype: isinstance(dtype, pd.DatetimeTZDtype),
        [datetime.datetime(2020, 10, 25, 10, 5, tzinfo=pytz.FixedOffset(-300))],
    ),
    (
        [datetime.datetime(2020, 10, 25, 10, 5, tzinfo=pytz.FixedOffset(-300))],
        '%Y-%m-%dT%I:%M:%S %p', lambda dtype: is_datetime64_dtype(dtype),
        [datetime.datetime(2020, 10, 25, 10, 5)],
    ),
    (
        [datetime.datetime.fromisoformat("2022-01-25")],
        '%d/%m/%Y %H:%M:%S', lambda dtype: is_datetime64_dtype(dtype),
        [datetime.datetime.fromisoformat("2022-01-25")],
    ),
    (
        [datetime.date.fromisoformat("2022-01-25")],
        '%Y/%m/%d', lambda dtype: is_datetime64_dtype(dtype),
        [datetime.date.fromisoformat("2022-01-25")],
    ),
])
def test_date_feature_serialization(
    data_format,
    data,
    dt_format,
    valid_dtype,
    expected_data
):
    # No warnings should be raised
    with warnings.catch_warnings():
        warnings.simplefilter(action="ignore", category=FutureWarning)
        warnings.simplefilter("error", append=True)
        columns = ['a']
        df = pd.DataFrame(pd.Series(data, name='a'))
        features = infer_feature_attributes(df)
        features['a']['date_time_format'] = dt_format
        if data_format == "numpy":
            df_numpy = np.array(data)
            df_numpy = np.array([[value] for value in df_numpy])
            cases = FeatureSerializer.serialize(df_numpy, columns, features)
        else:
            cases = FeatureSerializer.serialize(df, columns, features)
        json_data = json.dumps(sanitize_for_json(cases))
        new_df = FeatureSerializer.deserialize(
            json.loads(json_data), columns, features)
        expected_df = pd.DataFrame(pd.Series(expected_data, name='a'))

        difference = expected_df.compare(new_df)
        assert valid_dtype(new_df['a'].dtype)
        assert difference.empty


@pytest.mark.skipif(
    not has_locales(['fr_FR.utf8', 'fr_FR.iso88591']),
    reason="Test locale: 'fr_FR' is not available")
@pytest.mark.parametrize('data_format', ['pandas', 'numpy'])
@pytest.mark.parametrize(
    'language_code, encoding, category, dt_format, valid_dtype, data', [
        (
            'fr_FR', 'iso8859-1', locale.LC_ALL, '%a %d %B %Y %X',
            lambda dtype: isinstance(dtype, pd.DatetimeTZDtype),
            [datetime.datetime(2020, 11, 25, 10, 5,
                               tzinfo=pytz.timezone('Europe/Paris'))],
        ),
        (
            'fr_FR', 'iso8859-1', locale.LC_ALL, '%a %d %B %Y %X %z',
            lambda dtype: isinstance(dtype, pd.DatetimeTZDtype),
            [datetime.datetime(2020, 11, 25, 10, 5,
                               tzinfo=pytz.timezone('Europe/Paris'))],
        ),
        (
            'fr_FR', 'utf-8', locale.LC_ALL, '%a %d %B %Y %X %z',
            lambda dtype: isinstance(dtype, pd.DatetimeTZDtype),
            [datetime.datetime(2020, 11, 25, 10, 5,
                               tzinfo=pytz.timezone('Europe/Paris'))],
        ),
        (
            'fr_FR', None, locale.LC_ALL, '%a %d %B %Y %X',
            lambda dtype: isinstance(dtype, pd.DatetimeTZDtype),
            [datetime.datetime(2020, 11, 25, 10, 5)],
        ),
        (
            'fr_FR', None, locale.LC_ALL, '%a %d %B %Y %X',
            lambda dtype: isinstance(dtype, pd.DatetimeTZDtype),
            [datetime.date(2020, 11, 25)],
        ),
        (
            'fr_FR', 'utf-8', locale.LC_ALL, '%a %d %B %Y %X',
            lambda dtype: is_string_dtype(dtype),
            ["mer. 25 novembre 2020 01:00:00"],
        ),
    ]
)
def test_date_locale_serialization(
    data_format,
    data,
    dt_format,
    language_code,
    encoding,
    valid_dtype,
    category
):
    """Test datetimes using alternate locales."""
    # No warnings should be raised
    with warnings.catch_warnings():
        warnings.simplefilter("error")
        with LocaleOverride(language_code=language_code, encoding=encoding,
                            category=category):
            columns = ['a']
            df = pd.DataFrame(pd.Series(data, name='a'))
            features = infer_feature_attributes(df)
            features['a']['date_time_format'] = dt_format
            features['a']['locale'] = language_code
            if data_format == 'numpy':
                df_numpy = np.array(data)
                df_numpy = np.array([[value] for value in df_numpy])
                cases = FeatureSerializer.serialize(df_numpy, columns, features)
            else:
                cases = FeatureSerializer.serialize(df, columns, features)

        # Deserialize outside locale override to test locale handling of
        # feature deserialize
        json_data = json.dumps(sanitize_for_json(cases))
        new_df = FeatureSerializer.deserialize(
            json.loads(json_data), columns, features)

        difference = df.compare(new_df)
        assert valid_dtype(new_df['a'].dtype)
        assert difference.empty

howso/utilities/tests/test_features.py
howsoai/howso-engine-py
from collections.abc import Iterable
import warnings
from howso.utilities import internals
import pytest
from semantic_version import Version


@pytest.mark.parametrize(('features', 'result'), (
    (None, None),
    ({'test': {'type': 'ordinal'}}, {'test': {'type': 'ordinal'}}),
    ({'test': {'date_time_format': ''}}, {'test': {'date_time_format': ''}}),
    (
        {'test': {'date_time_format': '%Y-%m-%dT%H:%M:%S'}},
        {'test': {'date_time_format': '%Y-%m-%dT%H:%M:%S', 'decimal_places': 0}}
    ),
    (
        {'test': {'date_time_format': '%Y-%m-%dT%H:%M:%S-%f'}},
        {'test': {'date_time_format': '%Y-%m-%dT%H:%M:%S',
                  'original_format': {
                      'python': {'date_time_format': '%Y-%m-%dT%H:%M:%S-%f'}}
                  }}
    ),
    (
        {'test': {'date_time_format': '%H:%M:%S.%f'}},
        {'test': {'date_time_format': '%H:%M:%S',
                  'original_format': {
                      'python': {'date_time_format': '%H:%M:%S.%f'}
                  }}}
    ),
    (
        {'test': {'date_time_format': '%H:%M:%S,%fT%Y-%m-%d'}},
        {'test': {'date_time_format': '%H:%M:%ST%Y-%m-%d',
                  'original_format': {
                      'python': {'date_time_format': '%H:%M:%S,%fT%Y-%m-%d'}
                  }}}
    ),
    (
        {'test': {'date_time_format': '%H:%M:%S,%fT%Y-%m-%d'},
         'test2': {'date_time_format': '%H:%M:%S.%f'}},
        {'test': {'date_time_format': '%H:%M:%ST%Y-%m-%d',
                  'original_format': {
                      'python': {'date_time_format': '%H:%M:%S,%fT%Y-%m-%d'}
                  }},
         'test2': {'date_time_format': '%H:%M:%S',
                   'original_format': {
                       'python': {'date_time_format': '%H:%M:%S.%f'}
                   }}}
    ),
    (
        {'test': {'date_time_format': '%Y-%m-%d'},
         'test2': {'date_time_format': '%H:%M:%S.%f'},
         'test3': {'date_time_format': '%H:%M:%S'}},
        {'test': {'date_time_format': '%Y-%m-%d'},
         'test2': {'date_time_format': '%H:%M:%S',
                   'original_format': {
                       'python': {'date_time_format': '%H:%M:%S.%f'}
                   }},
         'test3': {'date_time_format': '%H:%M:%S', 'decimal_places': 0}}
    ),
))
def test_preprocess_feature_attributes(features, result):
    """Test preprocess_feature_attributes returns expected result."""
    output = internals.preprocess_feature_attributes(features)
    assert output == result


@pytest.mark.parametrize(('features', 'result'), (
    (None, None),
    ({'test': {'type': 'ordinal'}}, {'test': {'type': 'ordinal'}}),
    ({'test': {'date_time_format': ''}}, {'test': {'date_time_format': ''}}),
    (
        {'test': {'date_time_format': '%Y-%m-%dT%H:%M:%S'}},
        {'test': {'date_time_format': '%Y-%m-%dT%H:%M:%S'}}
    ),
    (
        {'test': {'date_time_format': '%Y-%m-%dT%H:%M:%S',
                  'original_format': {
                      'test': {'date_time_format': '%Y-%m-%d'}
                  }}},
        {'test': {'date_time_format': '%Y-%m-%dT%H:%M:%S',
                  'original_format': {
                      'test': {'date_time_format': '%Y-%m-%d'}
                  }}}
    ),
    (
        {'test': {'date_time_format': '%Y-%m-%dT%H:%M:%S',
                  'original_format': {
                      'python': {'date_time_format': '%Y-%m-%dT%H:%M:%S-%f'}
                  }}},
        {'test': {'date_time_format': '%Y-%m-%dT%H:%M:%S-%f',
                  'original_format': {
                      'python': {'date_time_format': '%Y-%m-%dT%H:%M:%S-%f'}
                  }}},
    ),
    (
        {'test': {'date_time_format': '%H:%M:%S',
                  'original_format': {
                      'python': {'date_time_format': '%H:%M:%S.%f', 'a': 'test'}
                  }}},
        {'test': {'date_time_format': '%H:%M:%S.%f',
                  'original_format': {
                      'python': {'date_time_format': '%H:%M:%S.%f', 'a': 'test'}
                  }}},
    ),
    (
        {'test': {'date_time_format': '%H:%M:%ST%Y-%m-%d',
                  'original_format': {
                      'python': {'date_time_format': '%H:%M:%S,%fT%Y-%m-%d'}
                  }}},
        {'test': {'date_time_format': '%H:%M:%S,%fT%Y-%m-%d',
                  'original_format': {
                      'python': {'date_time_format': '%H:%M:%S,%fT%Y-%m-%d'}
                  }}},
    ),
    (
        {'test': {'date_time_format': '%Y-%m-%d'},
         'test2': {'date_time_format': '%H:%M:%S',
                   'original_format': {
                       'python': {'date_time_format': '%H:%M:%S.%f'}
                   }},
         'test3': {'date_time_format': '%H:%M:%S'}},
        {'test': {'date_time_format': '%Y-%m-%d'},
         'test2': {'date_time_format': '%H:%M:%S.%f',
                   'original_format': {
                       'python': {'date_time_format': '%H:%M:%S.%f'}
                   }},
         'test3': {'date_time_format': '%H:%M:%S'}},
    ),
    # Test backwards compatibility shim
    (
        {'test': {'date_time_format': '%H:%M:%S',
                  'original_format': {'python': '%H:%M:%S.%f'}}},
        {'test': {'date_time_format': '%H:%M:%S.%f',
                  'original_format': {
                      'python': {'date_time_format': '%H:%M:%S.%f'}
                  }}},
    ),
))
def test_postprocess_feature_attributes(features, result):
    """Test postprocess_feature_attributes returns expected result."""
    output = internals.postprocess_feature_attributes(features)
    assert output == result


@pytest.mark.parametrize(
    'n_gen, n_requested, suppress_warning',
    ((10, 11, True), (10, 10, True), (10, 15, False))
)
def test_insufficient_case_generation_warnings(
    n_gen, n_requested, suppress_warning
):
    """
    Test to make sure `insufficient_generation_check` works.

    Parameters
    ----------
    requested_num_cases : int
        Number of cases requested by the user.
    gen_num_cases : int
        Number of cases actually generated.
    suppress_warning : bool, defaults to False
        (Optional) If True, warnings will be suppressed.
        By default, warnings will be displayed.
    """
    if n_gen < n_requested:
        # Got back less num cases, should warn the user if
        # suppress_warning is False
        if suppress_warning:
            with warnings.catch_warnings():
                warnings.simplefilter("error")
                internals.insufficient_generation_check(
                    n_requested, n_gen, suppress_warning=suppress_warning
                )
        else:
            with pytest.warns(RuntimeWarning):
                internals.insufficient_generation_check(
                    n_requested, n_gen, suppress_warning=suppress_warning
                )
    else:
        # Got back correct num cases, shouldn't warn the user
        with warnings.catch_warnings():
            warnings.simplefilter("error")
            internals.insufficient_generation_check(
                n_requested, n_gen, suppress_warning=suppress_warning
            )


@pytest.mark.parametrize('pandas_ver', ('2.0.0', '1.5.3'))
@pytest.mark.parametrize('format_str, is_iso', (
    ('%Y-%m-%d', True),
    ('%Y-%m-%d %H:%M:%S', True),
    ('%Y-%m-%dT%H:%M:%S', True),
    ('%Y-%m-%dT%H:%M:%SZ', True),
    ('%Y-%m-%dT%H:%M:%S%z', True),
    ('%Y-%m-%dT%H:%M:%S%Z', True),
    ('%Y-%m-%dT%H:%M:%S.%f', True),
    ('%Y-%m-%dT%H:%M:%S.%fZ', True),
    ('%Y-%m-%dT%H:%M:%S.%f%z', True),
    ('%Y-%m-%dT%H:%M:%S.%f%Z', True),
    ('%m-%d-%Y', False),
    ('%H:%M:%S', False),
    ('%Z', False),
    ('a%Y-%m-%d %H:%M:%S', False),
    ('%Y-%m-%dT%H:%M:%S.%fa', False),
))
def test_to_pandas_datetime_format(mocker, pandas_ver, format_str, is_iso):
    """Test pandas datetime format utility."""
    mocker.patch('pandas.__version__', pandas_ver)
    fmt = internals.to_pandas_datetime_format(format_str)
    ver = Version(pandas_ver)
    if ver.major is not None and ver.major >= 2:
        if is_iso:
            assert fmt == "ISO8601"
        else:
            assert fmt == format_str
    else:
        assert fmt == format_str


@pytest.mark.parametrize(
    'warning_type', (DeprecationWarning, FutureWarning, UserWarning)
)
def test_ignore_warnings_individual(warning_type):
    """Test that individual warnings are ignored."""

    def raise_future_warning(a, b):
        """Simple function that raises a Warning."""
        warnings.warn("Test Warning", warning_type)
        return a + b

    with pytest.warns(None) as warnings_list:
        with internals.IgnoreWarnings(warning_type):
            c = raise_future_warning(1, 2)

    assert len(warnings_list) == 0
    assert c == 3


def test_ignore_warnings_iterable(warning_type=[FutureWarning, UserWarning]):
    """Test that an iterable of warnings are ignored."""

    def raise_future_warning(a, b):
        """Simple function that raises a Warning."""
        for warning in warning_type:
            warnings.warn("Test Warning", warning)
        return a + b

    with pytest.warns(None) as warnings_list:
        with internals.IgnoreWarnings(warning_type):
            c = raise_future_warning(1, 2)

    assert len(warnings_list) == 0
    assert c == 3

howso/utilities/tests/test_internals.py
howsoai/howso-engine-py
from collections.abc import Iterable
from datetime import datetime
from dateutil import parser
import locale
import warnings


import howso.utilities as utils
from howso.utilities import get_kwargs, LocaleOverride
import pandas as pd
import pytest

from . import has_locales


@pytest.mark.skipif(
    not has_locales(['fr_FR.utf8']),
    reason="Test locale: 'fr_FR' is not available")
@pytest.mark.parametrize('language_code, encoding, category, result,', (
    # Tests simple parameters for a language_code and an encoding.
    ('fr_FR', 'utf-8', locale.LC_ALL, 'mar. 13 oct. 2020 17:02:27'),
    # Tests that utf-8 will be used by default
    ('fr_FR', None, locale.LC_TIME, 'mar. 13 oct. 2020 17:02:27'),
    # Tests that an explicit encoding (utf-8) overrides an embedded one.
    ('fr_FR.crazy_encoding_designation', 'utf-8', locale.LC_TIME,
        'mar. 13 oct. 2020 17:02:27'),
))
def test_locale_override(language_code, encoding, category, result):
    """
    Test that locale_override correctly switches context as desired.
    """
    dt = parser.parse('2020-10-13 17:02:27.243860T-0500')
    # Unfortunately just using `%c` is inconsistent across operating systems.
    format_str = '%a %d %b %Y %H:%M:%S'
    orig_locale = locale.getlocale(category=category)
    orig_dt_str = datetime.strftime(dt, format_str)

    # This will only be displayed in the output if the test fails and can
    # provide useful context.
    print([
        (k, v) for k, v in locale.locale_alias.items()
        if language_code.lower() in k
    ])

    # Ensure we were able to perform the switch for the duration of the body
    # of the context manager.
    with LocaleOverride(language_code=language_code, encoding=encoding,
                        category=category):
        date_str = datetime.strftime(dt, format_str)
    assert date_str == result

    # Now, ensure it's now back in the original locale
    assert locale.getlocale(category=category) == orig_locale
    assert locale.getlocale(category=category)[1].lower() == 'utf-8'
    assert datetime.strftime(dt, format_str) == orig_dt_str


def test_get_kwargs_simple_cases():
    """
    Test that providing a simple iterable with strings works as expected.
    """
    kwargs = {'apple': 5, 'banana': 6, 'cherry': 7}
    assert get_kwargs(kwargs, ('apple', 'banana', 'cherry')) == (5, 6, 7)


def test_get_kwargs_simple_extra_with_no_warnings():
    kwargs = {'apple': 5, 'banana': 6, 'cherry': 7, 'durian': 8}
    with warnings.catch_warnings():
        warnings.simplefilter("error")
        assert get_kwargs(kwargs, ('apple', 'banana', 'cherry')) == (5, 6, 7)


def test_get_kwargs_simple_extra_with_singular_warnings():
    kwargs = {'apple': 5, 'banana': 6, 'cherry': 7, 'durian': 8}
    with pytest.warns(UserWarning) as warn_record:
        assert get_kwargs(kwargs, ('apple', 'banana', 'cherry'),
                          warn_on_extra=True) == (5, 6, 7)
    assert 'This will be ignored.' in str(warn_record[0].message.args[0])


def test_get_kwargs_simple_extra_with_plural_warnings():
    kwargs = {'apple': 5, 'banana': 6, 'cherry': 7, 'durian': 8,
              'elderberry': 9}
    with pytest.warns(UserWarning) as warn_record:
        assert get_kwargs(kwargs, ('apple', 'banana', 'cherry'),
                          warn_on_extra=True) == (5, 6, 7)
    assert ('received unexpected parameters: [durian, elderberry]. These will '
            'be ignored.' in warn_record[0].message.args[0])


def test_get_kwargs_dict_descriptors_missing_item():
    kwargs = {'apple': 5, 'banana': 6}
    assert get_kwargs(kwargs, (
        {'key': 'apple', 'default': 5},
        {'key': 'banana', 'default': 6},
        {'key': 'cherry', 'default': 7},
    )) == (5, 6, 7)


def test_get_kwargs_dict_descriptors_missing_item_with_exception():
    kwargs = {'apple': 5, 'banana': 6}
    with pytest.raises(ValueError) as excinfo:
        get_kwargs(kwargs, (
            {'key': 'apple', 'default': 5},
            {'key': 'banana', 'default': 6},
            {'key': 'cherry', 'default': ValueError('Must provide "cherry".')},
        ))
    assert 'Must provide "cherry".' in str(excinfo.value)


def test_get_kwargs_dict_descriptors_with_tests():
    kwargs = {'apple': [5, ], 'banana': 6, 'cherry': []}

    def is_non_empty_iterable(value):
        return isinstance(value, Iterable) and len(value) > 0

    # Check the non-exception raising entries first
    assert (
        get_kwargs(kwargs, (
            {'key': 'apple', 'default': [5, ], 'test': is_non_empty_iterable},
            {'key': 'banana', 'default': [6, ], 'test': is_non_empty_iterable},
            {'key': 'cherry', 'default': [7, ], 'test': is_non_empty_iterable},
        )) == ([5, ], [6, ], [7, ])
    )

    # Now check the failing test for an existing item:
    with pytest.raises(ValueError) as excinfo:
        get_kwargs(kwargs, ({
            'key': 'cherry',
            'default': ValueError('Must provide a non-empty list for "cherry".'),  # noqa
            'test': is_non_empty_iterable,
        }, ))
    assert 'Must provide a non-empty list for "cherry".' in str(excinfo.value)

    # Now check the failing test for a non-existent item:
    with pytest.raises(ValueError) as excinfo:
        get_kwargs(kwargs, ({
            'key': 'durian',
            'default': ValueError('Must provide a non-empty list for "durian".'),  # noqa
            'test': is_non_empty_iterable,
        }, ))
    assert 'Must provide a non-empty list for "durian".' in str(excinfo.value)


def test_check_feature_names():
    """
    Test the `check_feature_names` under different scenarios.
    """
    # 1. Pass in a valid list
    columns = ["a", "b", "c"]
    features = {"a": 1, "b": 2, "c": 3}

    assert utils.check_feature_names(
        features, columns, raise_error=True
    ) is True

    # 2. Pass in a valid set
    columns = {"a", "b", "c"}
    features = {"a": 1, "b": 2, "c": 3}

    assert utils.check_feature_names(
        features, columns, raise_error=True
    ) is True

    # 3. Pass in a invalid list with `raise_error`=False (default)
    columns = ["a", "b"]
    features = {"a": 1, "b": 2, "c": 3}

    assert utils.check_feature_names(features, columns) is not True

    # 4. Pass in a invalid set `raise_error`=False (default)
    columns = {"a", "b"}
    features = {"a": 1, "b": 2, "c": 3}

    assert utils.check_feature_names(features, columns) is not True

    # 5. Pass in a invalid list with `raise_error`=True (default)
    columns = ["a", "b"]
    features = {"a": 1, "b": 2, "c": 3}

    with pytest.raises(ValueError) as error_:
        utils.check_feature_names(features, columns, raise_error=True)
        assert (
            "The feature names in `features` differs from `column_names`" in
            str(error_)
        )

    # 6. Pass in a invalid set with `raise_error`=True (default)
    columns = {"a", "b"}
    features = {"a": 1, "b": 2, "c": 3}

    with pytest.raises(ValueError) as error_:
        utils.check_feature_names(features, columns, raise_error=True)
        assert (
            "The feature names in `features` differs from `column_names`" in
            str(error_)
        )


def test_validate_case_indices():
    """
    Tests that 'validate_case_indices' correctly detects invalid case_indices arguments.
    """
    msg = 'Argument case_indices must be type Iterable of (non-string) Sequence[str, int].'
    # Test a case_indices that is not an iterable
    with pytest.raises(ValueError) as exc:
        utils.validate_case_indices(12345)
    assert str(exc.value) == msg

    # Test case_indices that do not contain sequences
    with pytest.raises(ValueError) as exc:
        utils.validate_case_indices([1, 2, 3])
    assert str(exc.value) == msg

    with pytest.raises(ValueError) as exc:
        utils.validate_case_indices([("test", 0), dict(idx=0), ["test", 1]])
    assert str(exc.value) == msg

    # Test case_indices that contain sequences with invalid types
    with pytest.raises(ValueError) as exc:
        utils.validate_case_indices([(1, "test"), ("test", 1)])
    assert str(exc.value) == msg

    with pytest.raises(ValueError) as exc:
        utils.validate_case_indices([("test", 1), ("test", dict(idx=0)), ["test", 3]])
    assert str(exc.value) == msg

    with pytest.raises(ValueError) as exc:
        utils.validate_case_indices([("test", 1), ("test", 2), 'a1'])
    assert str(exc.value) == msg

    # Test thorough validation
    long_invalid = [("valid", 0) for _ in range(1, 101)] + [(101, "invalid")]
    with pytest.raises(ValueError) as exc:
        utils.validate_case_indices(long_invalid, thorough=True)
    assert str(exc.value) == msg

    # A non-thorough call should not catch the invalid sequence
    utils.validate_case_indices(long_invalid)

    # Test case_indices that are valid. No exception should be raised.
    utils.validate_case_indices([("test", 1)])
    utils.validate_case_indices([["test", 0], ("test", 1)])


def test_build_react_series_df():
    """
    Tests that build_react_series_df correctly builds the DataFrame and that it includes
    the series index feature when specified.
    """
    test_react_series_response = {
        'action_features': ['id', 'x', 'y'],
        'series': [
            [["A", 1, 2], ["A", 2, 2]],
            [["B", 4, 4], ["B", 6, 7], ["B", 8, 9]]
        ]
    }

    # Without the series index feature
    columns = test_react_series_response['action_features']
    expected_data = [["A", 1, 2], ["A", 2, 2], ["B", 4, 4], ["B", 6, 7], ["B", 8, 9]]
    expected_df = pd.DataFrame(expected_data, columns=columns)
    df = utils.build_react_series_df(test_react_series_response)
    assert df.equals(expected_df)

    # With the series index feature
    columns = [".series"] + columns
    expected_data = [
        ["series_1", "A", 1, 2],
        ["series_1", "A", 2, 2],
        ["series_2", "B", 4, 4],
        ["series_2", "B", 6, 7],
        ["series_2", "B", 8, 9]
    ]
    expected_df = pd.DataFrame(expected_data, columns=columns)
    df = utils.build_react_series_df(test_react_series_response, series_index='.series')
    assert df.equals(expected_df)


@pytest.mark.parametrize('date_str, format_str', (
    ("2020-01-01", "%Y-%m-%d"),
    ("2020-01-01T20:10:10", "%Y-%m-%dT%H:%M:%S"),
    ("2020-01-01T20:10:10.123", "%Y-%m-%dT%H:%M:%S.%f"),
    ("2020-01-01T20:10:10+0000", "%Y-%m-%dT%H:%M:%S%z"),
    ("2020-01-01T20:10:10-0000", "%Y-%m-%dT%H:%M:%S%z"),
    ("2020-01-01T20:10:10+05:00", "%Y-%m-%dT%H:%M:%S%z"),
    ("2020-01-01T20:10:10UTC", "%Y-%m-%dT%H:%M:%S%Z"),
    ("2020-01-01T20:10:10Z", "%Y-%m-%dT%H:%M:%SZ"),
    ("2020-01-01T20:10:10.123Z", "%Y-%m-%dT%H:%M:%S.%fZ"),
))
def test_determine_iso_format(date_str, format_str):
    with warnings.catch_warnings():
        warnings.simplefilter("error")
        assert format_str == utils.determine_iso_format(date_str, "_")

howso/utilities/tests/test_utilities.py
howsoai/howso-engine-py
import datetime as dt
import inspect
import locale as python_locale
from math import isnan
import re
import sys
import threading
from typing import (
    Collection, Dict, Iterable, List, Mapping, Optional, Tuple, Union)
import uuid
import warnings

from dateutil.parser import isoparse
from dateutil.parser import parse as dt_parse
from dateutil.tz import tzoffset
from howso.openapi.models import (
    FeatureAttributes,
    Trainee
)
import numpy as np
import pandas as pd

from .internals import serialize_openapi_models


_BASE_FEATURE_TYPES = ["nominal", "continuous", "ordinal"]
# Custom type for case_indices parameter
CaseIndices = Iterable[Union[List[Union[str, int]], Tuple[Union[str, int]]]]
DATETIME_TIMEZONE_PATTERN = re.compile(r"(?<!%)(?:%%)*(%z)", re.IGNORECASE)
DATETIME_UTC_Z_PATTERN = re.compile(r"\dZ$")
EPOCH = dt.datetime.utcfromtimestamp(0)
ISO_8601_DATE_FORMAT = "%Y-%m-%d"
ISO_8601_FORMAT = "%Y-%m-%dT%H:%M:%S"
ISO_8601_FORMAT_FRACTIONAL = "%Y-%m-%dT%H:%M:%S.%f"
# The number of Sequences to check in a non-thorough validation of case_indices
NON_THOROUGH_NUM = 100
# Match unescaped timezone character in datetime format strings
SMALLEST_TIME_DELTA = 0.001


def trainee_from_df(df, features: Optional[Mapping[str, Mapping]] = None,
                    action_features: Optional[Iterable[str]] = None,
                    name: Optional[str] = None,
                    persistence: str = 'allow',
                    trainee_metadata: Optional[Mapping] = None,
                    ) -> Trainee:
    """
    Create a Trainee from a dataframe.

    Assumes floats are continuous and all other values are nominal.

    Parameters
    ----------
    df : pandas.DataFrame
        A pandas Dataframe with column names corresponding to feature
        names.Features that are considered to be continuous should have a dtype
        of float.

    features : Optional[Mapping[str, Mapping]]
        (Optional) A dictionary of feature names to a dictionary of parameters.

    action_features : List of String, Default None
        (Optional) List of action features. Anything that's not in this list
        will be treated as a context feature. For example, if no action feature
        is specified the trainee won't have a target.

    name : str or None, defaults to None
        (Optional) The name of the trainee.

    persistence : str: default "allow"
        The persistence setting to use for the trainee. Valid values:
        "always", "allow", "never".

    trainee_metadata : Mapping, optional
        (Optional) mapping of key/value pairs of metadata for trainee.

    Returns
    -------
    howso.openapi.models.Trainee
        A trainee object
    """
    # Place this here to avoid circular imports
    from howso.utilities.feature_attributes import infer_feature_attributes
    action_features = [] if action_features is None else list(action_features)

    if features is None:
        features = infer_feature_attributes(df)

    context_features = []
    for fname, _ in features.items():
        if fname not in action_features:
            context_features.append(fname)

    return Trainee(name, features=features,
                   default_context_features=context_features,
                   default_action_features=action_features,
                   persistence=persistence,
                   metadata=trainee_metadata)


def date_to_epoch(
    date_obj: Union[dt.date, dt.datetime, dt.time, str],
    time_format: str
) -> Optional[Union[str, float]]:
    """
    Convert date into epoch (i.e seconds counted from Jan 1st 1970).

    .. note::
        If `date_str` is None or nan, it will be returned as is.

    Parameters
    ----------
    date_obj : str or datetime.date or datetime.time or datetime.datetime
        Time object.
    time_format : str
        Specify format of the time.
        Ex: ``%a %b %d %H:%M:%S %Y``

    Returns
    -------
    Union[str, float]
        The epoch date as a floating point value or 'np.nan', et al.
    """
    # pd.isnull covers the cases - None, `np.nan` and `pd.na`
    if pd.isnull(date_obj):
        return date_obj

    # if timestamp is passed in, convert it to string in the correct
    # format first
    if isinstance(date_obj, (dt.date, dt.datetime)):
        date_str = date_obj.strftime(time_format)
    elif isinstance(date_obj, dt.time):
        return time_to_seconds(date_obj)
    else:
        date_str = str(date_obj)

    # if there is time zone info in the format, use dt_parse because
    # datetime.strptime doesn't handle time zones
    if DATETIME_TIMEZONE_PATTERN.findall(time_format):
        datetime_object = dt_parse(date_str)
        time_zero = dt.datetime(1970, 1, 1, tzinfo=datetime_object.tzinfo)
    else:
        datetime_object = dt.datetime.strptime(date_str, time_format)
        time_zero = dt.datetime(1970, 1, 1)

    return (datetime_object - time_zero).total_seconds()


def epoch_to_date(epoch: Union[str, float], time_format: str,
                  tzinfo: Optional[dt.tzinfo] = None) -> str:
    """
    Convert epoch to date if epoch is not `None` or `nan` else, return as it is.

    Parameters
    ----------
    epoch : Union[str, float]
        The epoch date as a floating point value (or str if np.nan, et al)
    time_format : str
        Specify format of the time.
        Ex: ``%a %b %d %H:%M:%S %Y``
    tzinfo : datetime.tzinfo, optional
        Time zone information to include in datetime.

    Returns
    -------
    str
        A date string in the format similar to "Wed May 21 00:00:00 2008"
    """
    # pd.isnull covers the cases - None, `np.nan` and `pd.na`
    if pd.isnull(epoch):
        return epoch

    dt_value = (EPOCH + dt.timedelta(seconds=epoch))
    if tzinfo is not None:
        dt_value = dt_value.replace(tzinfo=tzinfo)
    return dt_value.strftime(time_format)


def time_to_seconds(time: Optional[dt.time]) -> Optional[float]:
    """
    Convert a time object to seconds since midnight.

    Parameters
    ----------
    time : datetime.time
        The time to convert.

    Returns
    -------
    float
        Seconds since midnight.
    """
    if not isinstance(time, dt.time):
        return None
    date = dt.datetime.combine(dt.date.min, time)
    delta = date - dt.datetime(1, 1, 1, tzinfo=time.tzinfo)
    return delta.total_seconds()


def seconds_to_time(seconds: Union[int, float, None], *,
                    tzinfo: Optional[dt.tzinfo] = None) -> Optional[dt.time]:
    """
    Convert seconds to a time object.

    Parameters
    ----------
    seconds : int or float
        The seconds to convert to time.
    tzinfo : datetime.tzinfo, optional
        Time zone to use for resulting time object.

    Returns
    -------
    datetime.time
        The time object.
    """
    if pd.isnull(seconds):
        return None
    time_value = (dt.datetime.min + dt.timedelta(seconds=seconds)).time()
    if tzinfo:
        return time_value.replace(tzinfo=tzinfo)
    return time_value


def replace_none_with_nan(dat: Mapping) -> List[Dict]:
    """
    Replace None values with NaN values.

    For use when retrieving data from Howso via the scikit module to
    conform to sklearn convention on missing values.

    Parameters
    ----------
    dat : list of dict of key-values

    Returns
    -------
    list[dict]
    """
    return [
        {
            key: float('nan') if value is None else value
            for key, value in action.items()
        } for action in dat
    ]


def replace_nan_with_none(dat):
    """
    Replace None values with NaN values.

    For use when feeding data to Howso from the scikit module to account
    for the different ways howso and sklearn represent missing values.

    Parameters
    ----------
    dat : list of list of object
        A 2d list of values.

    Returns
    -------
    list[list[object]]
    """
    return [[None if isinstance(value, (int, float)) and isnan(value) else
             value for value in case] for case in dat]


def reshape_data(x, y):
    """
    Reshapes X as a matrix and y as a vector.

    Parameters
    ----------
    x : np.ndarray
        Feature values ndarray.
    y : np.ndarray
        target values ndarray.

    Returns
    -------
    np.ndarray, np.ndarray
        X, y
    """
    if len(x.shape) < 2:
        x = x.reshape(-1, 1)
    if len(y.shape) > 1:
        y = y.reshape(-1)
    return x, y


def align_data(x, y=None):
    """
    Check and fix type problems with the data and reshape it.

    x is a Matrix and y is a vector.

    Parameters
    ----------
    x : numpy.ndarray
        Feature values ndarray.
    y : numpy.ndarray, default None
        Target values ndarray.

    Returns
    -------
    numpy.ndarray, numpy.ndarray or numpy.ndarray
    """
    if x.dtype == object:
        x = x.astype(float)
    if len(x.shape) < 2:
        x = x.reshape(-1, 1)
    if y is not None:
        if y.dtype == object:
            y = y.astype(float)
        return x, y
    return x


def replace_doublemax_with_infinity(dat):
    """
    Replace values of Double.MAX_VALUE (1.79769313486232E+308) with Infinity.

    For use when retrieving data from Howso.

    Parameters
    ----------
    dat : A dict, list, number, or string

    Returns
    -------
    A dict, list, number, or string - same as passed in for translation
    """
    if isinstance(dat, dict):
        dat = {k: replace_doublemax_with_infinity(v) for (k, v) in dat.items()}
    elif isinstance(dat, list):
        dat = [replace_doublemax_with_infinity(item) for item in dat]
    elif dat == sys.float_info.max:
        dat = float('inf')

    return dat


def dprint(debug, *argc, **kwargs):
    """
    Print based on debug levels.

    Parameters
    ----------
    debug : bool or int
        If true, user_debug level would be 1.
        Possible levels: 1, 2, 3 (print all)
    kwargs:
        default_priority : int, default 1
            The message is printed only if the `debug` >= `default_priority`.

    Examples
    --------
    >>> dprint(True, "hello", "howso", priority=1)
    `hello howso`
    """
    if debug:
        user_priority = debug
        if not isinstance(debug, int):
            user_priority = 1

        priority = kwargs.get("default_priority", 1)
        if user_priority >= priority:
            for item in argc:
                print(item, end=" ")
            if "end" in kwargs:
                print("", end=kwargs["end"])
            else:
                print()


def determine_iso_format(str_date: str, fname: str) -> str:  # noqa: C901
    """
    Determine which specific ISO8601 format the passed in date is in.

    Specifically if it's just a date, if it's zoned, and if zoned, whether it's
    a zone or an offset.

    Parameters
    ----------
    str_date: str
        The Date time passed in as a string.
    fname: str
        Name of feature to guess bounds for.

    Returns
    -------
    str
        The ISO_8601 format string that most matches the passed in date.
    """
    # parse with the standard parser first to support single digit month/day
    dt_object = dt_parse(str_date)
    if dt_object.tzinfo is None:
        # warn user if this date format is a subset of the ISO_8601
        warn_user = True

        try:
            # do a stricter check for iso format
            isoparse(str_date)
        except Exception:  # noqa: Intentionally broad
            # user was already warned, don't warn them again
            warn_user = False

        if len(str_date) <= 10:
            try:
                dt.datetime.strptime(str_date, ISO_8601_DATE_FORMAT)
                return ISO_8601_DATE_FORMAT
            except ValueError:
                if warn_user:
                    warnings.warn(f"Feature {fname} is a datetime but may not "
                                  f"work properly if user doesn't specify "
                                  f"the correct format.")
                return ISO_8601_FORMAT

        try:
            dt.datetime.strptime(str_date, ISO_8601_FORMAT_FRACTIONAL)
            return ISO_8601_FORMAT_FRACTIONAL
        except ValueError:
            pass

        try:
            dt.datetime.strptime(str_date, ISO_8601_FORMAT)
            return ISO_8601_FORMAT
        except ValueError:
            if warn_user:
                warnings.warn(f"Feature {fname} is a datetime but may not "
                              f"work properly if user doesn't specify "
                              f"the correct format.")
            return ISO_8601_FORMAT

    # detect iso formats ending in Z, signifying UTC
    if (
        dt_object.utcoffset() == dt.timedelta(0) and
        DATETIME_UTC_Z_PATTERN.findall(str_date)
    ):
        try:
            dt.datetime.strptime(str_date, ISO_8601_FORMAT_FRACTIONAL + "Z")
            return ISO_8601_FORMAT_FRACTIONAL + "Z"
        except ValueError:
            return ISO_8601_FORMAT + "Z"

    # date has time zone info, determine whether it's an offset
    if isinstance(dt_object.tzinfo, tzoffset):
        return "%Y-%m-%dT%H:%M:%S%z"

    # offsets of +0000 or -0000 won't parse as a TZ offset, but
    # the format still matches using %z, thus check if last char is '0'
    if str_date[-1] == '0':
        return "%Y-%m-%dT%H:%M:%S%z"

    return "%Y-%m-%dT%H:%M:%S%Z"


def validate_list_shape(values: Union[Collection, None], dimensions: int,
                        variable_name: str, var_types: str,
                        allow_none: bool = True
                        ) -> None:
    """
    Validate the shape of a list.

    Raise a ValueError if it does not match expected number of dimensions.

    Parameters
    ----------
    values : Collection or None
        A single or multidimensional list.
    dimensions : int
        The number of dimensions the list should be.
    variable_name : str
        The variable name for output.
    var_types : str
        The expected type of the data.
    allow_none : bool, default True
        If None should be allowed.
    """
    if values is None:
        if not allow_none:
            raise ValueError(
                f"Invalid value for `{variable_name}`, must not be `None`")
        return
    if len(np.array(values).shape) != dimensions:
        raise ValueError(
            f"Improper shape of `{variable_name}` values passed. "
            f"`{variable_name}` must be a {dimensions}d list of {var_types}.")


def validate_case_indices(case_indices: CaseIndices, thorough=False) -> None:
    """
    Validate the case_indices parameter to the react() method of a Howso client.

    Raises a ValueError if case_indices has sequences that do not contain the expected
    data types of (str, int).

    Parameters
    ----------
    case_indices : Iterable of Sequence[str, int]
        The case_indices argument to validate.
    thorough : bool, default False
        Whether to verify the data types in all sequences or only some (for performance)
    """
    try:
        amount_to_verify = case_indices[:len(case_indices) if thorough else NON_THOROUGH_NUM]
        if (
            not all((
                len(sequence) == 2 and
                not isinstance(sequence, str) and
                isinstance(sequence[0], str) and
                isinstance(sequence[1], (np.integer, int))
            ) for sequence in amount_to_verify)
        ):
            raise ValueError()
    except (TypeError, ValueError):
        raise ValueError('Argument case_indices must be type Iterable of (non-string) Sequence[str, int].')


def num_list_dimensions(lst):
    """
    Return number of dimensions for a list.

    Assumption is that the input nested lists are also lists,
    or a list of dataframes.

    Parameters
    ----------
    lst : list
        The nested list of objects.

    Returns
    -------
    int
        The number of dimensions in the passed in list.
    """
    the_list = lst
    d = 0
    while True:
        if not isinstance(the_list, list):
            if isinstance(the_list, pd.DataFrame):
                # add the number of dimensions in the dataframe
                d += the_list.ndim
            break
        try:
            the_list = the_list[0]
            d += 1
        except (IndexError, TypeError):
            break
    return d


def validate_features(features: Mapping[str, Union[FeatureAttributes, Mapping]],
                      extended_feature_types: Optional[Iterable[str]] = None
                      ) -> None:
    """
    Validate the feature types in `features`.

    Parameters
    ----------
    features : dict
        The dict of feature name to feature attributes.

        The valid feature names are:

        a. "nominal"
        b. "continuous"
        c. "ordinal"
        d. along with passed in `extended_feature_types`
    extended_feature_types : list of str, optional
        (Optional) If a list is passed in, the feature types specified in the
        list will be considered as valid features.
    """
    valid_feature_types = _BASE_FEATURE_TYPES

    if extended_feature_types is not None:
        # Append extended types to valid feature types
        valid_feature_types += list(extended_feature_types)

    for f_name, f_desc in features.items():
        if isinstance(f_desc, FeatureAttributes):
            f_type = f_desc.type
        else:
            f_type = f_desc.get("type")
        if f_type not in valid_feature_types:
            raise ValueError(f"The feature name '{f_name}' has invalid "
                             f"feature type - '{f_type}'")


def validate_datetime_iso8061(datetime_value, feature):
    """
    Check that the passed in datetime value adheres to the ISO 8601 format.

    Warn the user if it doesn't check out.

    Parameters
    ----------
    datetime_value : str
        The date value as a string
    feature : str
        Name of feature
    """
    try:
        # general iso8601 checker, allows various 8601 formats
        isoparse(datetime_value)
    except Exception:  # noqa: Intentionally broad
        warnings.warn(
            f"Feature {feature} detected as having datetime values, but are "
            f"not in an ISO 8601 format, such as '{ISO_8601_FORMAT}', for "
            f"example: '2020-10-02T12:43:39'")


def serialize_datetimes(cases: List[List], columns: Iterable[str],  # noqa: C901
                        features: Dict, *, warn: bool = False) -> None:
    """
    Serialize datetimes in the given list of cases, in-place.

    Iterate over the passed in case values and serializes any datetime
    values according to the specified datetime format in feature attributes.

    Parameters
    ----------
    cases : list of list
        A 2d list of case values corresponding to the features of the cases.
    columns : list of str
        A list of feature names.
    features : dict
        Dictionary of feature attributes.
    warn : bool, default: False
        If set to true, will warn user when specified datetime format
        doesn't match the datetime strings.
    """
    # Import here to avoid circular import dependencies
    from .features import FeatureType
    features = serialize_openapi_models(features)
    if isinstance(columns, Iterable):
        columns = list(columns)

    # Populate the list of indices for the specified features for all
    # features that contain datetime values.
    dt_indices = list()
    for i, feature_name in enumerate(columns):
        if feature_name not in features:
            # Feature has no feature attributes, skip
            continue
        feature = features[feature_name]
        date_time_format = feature.get("date_time_format")
        try:
            original_data_type = feature['original_type']['data_type']
        except (TypeError, KeyError):
            original_data_type = None

        if (
            date_time_format is not None or
            original_data_type == FeatureType.TIME.value
        ):
            # If datetime format defined or time only without format we need to
            # serialize the value
            dt_indices.append(i)

    if not dt_indices:
        # If no datetime features found, nothing to serialize
        return

    warned_features = set()
    for case in cases:
        if case is None:
            continue

        for i in dt_indices:
            feature_name = columns[i]
            feature = features[feature_name]
            dt_value = case[i]
            dt_format = feature.get("date_time_format")
            locale = feature.get("locale")

            # NaN may be passed from a dataframe if a value is null.
            if repr(dt_value).lower() in ['nan', 'inf', 'nat']:
                dt_value = None
            # if the value is a datetime object, just serialize it
            elif isinstance(dt_value, (dt.date, dt.datetime)):
                if pd.isnull(dt_value):
                    dt_value = None
                else:
                    dt_value = dt_value.strftime(dt_format)
            elif isinstance(dt_value, dt.time):
                if pd.isnull(dt_value):
                    dt_value = None
                elif not dt_format:
                    dt_value = time_to_seconds(dt_value)
                else:
                    dt_value = dt.datetime.combine(dt.date(1970, 1, 1),
                                                   dt_value)
                    dt_value = dt_value.strftime(dt_format)
            # deal with string dates using default locale
            elif isinstance(dt_value, str):
                if locale is None:
                    bad_format = False
                    try:
                        # convert to datetime object, then serialize
                        dt_value = dt.datetime.strptime(
                            dt_value, dt_format
                        ).strftime(dt_format)
                    except ValueError:
                        bad_format = True
                        if warn and feature_name not in warned_features:
                            warnings.warn(
                                f"{feature_name} has values with incorrect "
                                f"datetime format, should be {dt_format}. "
                                f"This feature may not work properly."
                            )
                            warned_features.add(feature_name)

                    # if the format was ISO_8601 but the date string
                    # didn't match it, attempt to parse it
                    if bad_format and dt_format in ISO_8601_FORMAT:
                        try:
                            dt_object = dt_parse(dt_value)
                            dt_value = dt.datetime.strftime(dt_object,
                                                            dt_format)
                        except Exception:  # noqa: Intentionally broad
                            # do nothing because we already know that this is
                            # a bad format value and already warned the user
                            pass
                else:
                    # Given a locale string, use it instead of the system
                    # locale.
                    # NOTE: The provided locale must be installed on the
                    # system.
                    try:
                        with LocaleOverride(language_code=locale,
                                            category=python_locale.LC_TIME):
                            dt_value = (
                                dt.datetime.strptime(dt_value, dt_format)
                                           .strftime(dt_format))
                    except python_locale.Error:
                        warnings.warn(
                            f"The locale provided: '{locale}' does not appear "
                            f"to be available on this system. The provided "
                            f"value is left unchanged. This feature may not "
                            f"work properly until it is installed.")
                    except (TypeError, ValueError):
                        warnings.warn(
                            f"{feature_name} has values with incorrect "
                            f"datetime format for the given locale, should be "
                            f"{dt_format}. The provided value is left "
                            f"unchanged. This feature may not work properly.")
            else:
                # At this point, no valid date type has been processed. This
                # could mean the value is None which is an acceptable input.
                # Otherwise it meant that the value is malformed or of the
                # wrong type (ex int or other non datetime object) and a
                # warning should be issued and the value replaced with None.
                if dt_value is not None:
                    warnings.warn(
                        f"{feature_name} has a malformed value {dt_value} "
                        f"that cannot be parsed. Expected datetime formatted "
                        f"string or object. Replacing with None.")
                dt_value = None

            # store the serialized datetime value
            case[i] = dt_value


def is_valid_uuid(value, version=4):
    """
    Check if a given string is a valid uuid.

    Parameters
    ----------
    value : str or UUID
        The value to test
    version : int, optional
        The uuid version (Default: 4)

    Returns
    -------
    bool
        True if `value` is a valid uuid string
    """
    try:
        uuid_obj = uuid.UUID(str(value), version=version)
    except (TypeError, ValueError):
        return False
    return str(uuid_obj) == value


class LocaleOverride:
    """
    Implements a thread-safe context manager for switching locales temporarily.

    Background
    ----------
    Python's locale.setlocale() is not thread safe. In order to work with
    alternate locales temporarily, this ContextDecorator will use a thread
    lock on __enter__ and release said lock on __exit__.

    Important Notes
    ---------------
    All other threads will be blocked within the scope of the context. It is
    important to avoid time-consuming execution inside.

    Example Usage
    -------------
    >>> # Parse date string from French and format it in English.
    >>>
    >>> # System locale is 'en-us' (in this example)
    >>> from datetime import datetime
    >>> dt_format = '<some format>'
    >>> dt_obj = datetime()
    >>> with locale_override('fr-fr', category=locale.LC_DATE):
    >>>     # We're in French date-formatting zone here...
    >>>     date_obj = datetime.strptime(dt_value, dt_format)
    >>>
    >>> # Back in the 'en-us' locale again.
    >>> dt_value = dt_obj.strftime(dt_format)

    Parameters
    ----------
    language_code : str
        A language code /usually/ given as either:
            - 2 lower case letters for the base language Ex: `fr` for French.
            - 5 characters such as `fr_CA` where the first 2 designate the
              base language (French in this example) followed by an `_`
              followed by 2 upper case characters designating the country-
              specific dialect (Canada, in this example). This example
              designates the French-Canadian locale.
            - Any of the above, plus an optional encoding following a '.' Ex:
              `fr_FR.UTF-8`
    encoding : str
        An encoding such as 'UTF-8' or 'ISO8859-1', etc. If not provided and
        there is no embedded encoding within the language_code parameter,
        'UTF-8' is used. If an encoding is embedded in the `language_code`
        parameter and an explicit encoding provided here, the embedded encoding
        is dropped and ignored.
    category : int
        This is one of the constants set within the locale object.
        See: https://docs.python.org/3.9/library/locale.html for details.
        `locale.LC_ALL` is used if nothing provided.
    """

    def __init__(self, language_code, encoding=None,
                 category=python_locale.LC_ALL):
        """Construct the context manager."""
        if '.' in language_code:
            language_code, embedded_encoding = language_code.split('.', 1)
        else:
            embedded_encoding = 'UTF-8'

        if encoding is None:
            encoding = embedded_encoding

        self.new_locale = (language_code, encoding)
        self.category = category
        self.lock = threading.Lock()

    def setup(self):
        """
        Set a thread lock and the locale as desired.

        Use this method directly to setup a locale context when not using this
        class as a context manager.
        """
        self.lock.acquire()
        self.old_locale = python_locale.getlocale(self.category)
        python_locale.setlocale(self.category, self.new_locale)

    def restore(self):
        """
        Restore the original locale and release the thread lock.

        Use this method directly to restore the current context when not using
        this class as a context manager.
        """
        python_locale.setlocale(self.category, self.old_locale)
        if self.lock and self.lock.locked():
            self.lock.release()

    def __enter__(self):
        """Set a thread lock and the locale as desired."""
        self.setup()

    def __exit__(self, exc_type, exc_value, traceback):
        """Restore the original locale and release the thread lock."""
        self.restore()


class StopExecution(Exception):
    """Raise a StopExecution as this is a cleaner `exit()` for Notebooks."""

    def _render_traceback_(self):
        pass


class UserFriendlyExit:
    """
    Return a callable that, when called, simply prints `msg` and cleanly exits.

    Parameters
    ----------
    verbose : bool
        If True, emit more information
    """

    def __init__(self, verbose=False):
        """Construct a UserFriendlyExit instance."""
        self.verbose = verbose

    def __call__(self, msg="An unexpected exit occurred.", exception=None):
        """
        Exit, but print the exception first.

        Parameters
        ----------
        msg : str
            The **user-friendly** message to display before exiting.
        exception : Exception
            An exception to produce the message from.

        Returns
        -------
        None
        """
        print("Howso Client Error: " + msg)
        if self.verbose and exception is not None:
            print("More information:\n")
            print(str(exception))

        raise StopExecution


def get_kwargs(kwargs, descriptors, warn_on_extra=False):  # noqa: C901
    """
    Decompose kwargs into a tuple of return values.

    Each tuple corresponds to a descriptor in 'descriptors'. Optionally issue a
    warning on any items in `kwargs` that are not "consumed" by the descriptors.

    Parameters
    ----------
    kwargs : dict
        Mapping of keys and values (kwargs)

    descriptors :
        An iterable of descriptors for how to handle each item in kwargs. Each
        descriptor can be a mapping, another iterable, or a single string.

        If a mapping, it must at least include the key: 'key' but can also
        optionally include the keys: 'default' and 'test'.

        If a non-mapping iterable, the values will be interpreted as 'key'
        'default', 'test, in that order. Only the first is absolutely required
        the remaining will be evaluated to `None` if not provided.

        If a string provided, it is used as the 'key'. 'default' and 'test are
        set to `None`.

        If a 'key' is not found in the kwargs, then the 'default' value is
        returned.

        If a descriptor contains a 'test', it should be a callable that returns
        a boolean. If False, the 'default' value is returned.

        If the 'default' provided is an instance of an Exception, then, the
        exception is raised when the 'key' is not present, or the 'test' fails.

    warn_on_extra : bool
        If `True`, will issue warnings about any keys provided in kwargs that
        were not consumed by the descriptors. Default is `False`

    Returns
    -------
        A tuple of the found values in the same order as the
        provided descriptor.

    Raises
    ------
        May raise any exception given as a 'default' in the
        `descriptors` parameter.

    Usage
    -----
    An example of usage showing various ways to use descriptors:

    >>> def my_method(self, required, **kwargs):
    >>>     apple, banana, cherry, durian, elderberry = get_kwargs(kwargs, (
    >>>         # A simple string is interpreted as the 'key' with 'default of
    >>>         # `None` and no test. Very common use-case made simple.
    >>>         'apple',
    >>>
    >>>         # Another common use-case. Set value to 5 if not in kwargs.
    >>>         # This also shows using an tuple for the descriptor.
    >>>         ('banana', 5),
    >>>
    >>>         # Verbose input including a test using dict
    >>>         {'key': 'cherry', 'default': 5, 'test': lambda x: x > 0},
    >>>
    >>>         # The test, `is_durian`, is defined elsewhere
    >>>         ('durian', None, is_durian),
    >>>
    >>>         # Full example using iterable descriptor rather than mapping.
    >>>         ('elderberry', ValueError('"elderberry" must be > 5.'),
    >>>             lambda x: x > 5),
    >>>     ))

    """
    returns = []
    for descriptor in descriptors:
        if isinstance(descriptor, Mapping):
            key = descriptor['key']  # 'key' is required
            default = descriptor.get('default', None)
            test = descriptor.get('test', None)
        elif isinstance(descriptor, str):
            # A naked key name was provided.
            key, default, test = descriptor, None, None
        elif isinstance(descriptor, Collection):
            descriptor = list(descriptor)
            descriptor.extend([None, None])
            key, default, test = descriptor[0:3]
        else:
            raise ValueError('Each item of `descriptors` should be either a '
                             'dict, an ordered Iterable or a string.')

        try:
            value = kwargs.pop(key)
        except KeyError:
            # If the value doesn't exist, set the value to the default, unless
            # the default given is an instance of an exception, in that case,
            # raise it.
            if isinstance(default, Exception):
                raise default
            else:
                value = default

        if callable(test):
            if test(value):
                returns.append(value)
            else:
                if isinstance(default, Exception):
                    raise default
                else:
                    returns.append(default)
        else:
            returns.append(value)

    if warn_on_extra:
        # Attempt to provide the context of the caller...
        try:
            caller = inspect.stack()[1][3]
        except Exception:  # noqa: Intentionally broad
            caller = '[unknown]'
        num_extra_kwargs = len(kwargs)
        if num_extra_kwargs == 1:
            unexpected_param = list(kwargs.keys())[0]
            warnings.warn(f'The function/method "{caller}" received an '
                          f'unexpected parameter "{unexpected_param}". '
                          f'This will be ignored.')
        elif num_extra_kwargs > 1:
            params = ', '.join(list(kwargs.keys()))
            warnings.warn(f'The function/method "{caller}" received '
                          f'unexpected parameters: [{params}]. '
                          f'These will be ignored.')

    return tuple(returns)


def check_feature_names(features: Mapping,
                        expected_feature_names: Collection,
                        raise_error: bool = False) -> bool:
    """
    Check if features in `features` dict matches `expected_feature_names`.

    Parameters
    ----------
    features : Mapping
        A feature dictionary that maps feature names to its attributes.
    expected_feature_names : Collection
        A list (or a set) of expected column names in the given `features`
        dictionary.
    raise_error : bool, defaults to False
        Raise a value error in case the feature names doesn't match between
        `features` and `expected_feature_names`.

    Returns
    -------
    bool
        Returns `True` if the feature names in `features` matches the
        expected feature names passed via `expected_feature_names`. Otherwise,
        returns `False`.

    Raises
    ------
        If `raise_error` is `True`, raises `ValueError` to indicate that
        the feature names in `features` dict doesn't match the feature names
        `expected_feature_names`.
    """
    feature_names = set(features.keys())

    if isinstance(expected_feature_names, list):
        expected_feature_names = set(expected_feature_names)

    ret_value = True
    if feature_names != expected_feature_names:
        ret_value = False
        if raise_error:
            raise ValueError(
                f"The feature names in `features` differs from "
                f"`expected_feature_names`\n"
                f"Trained features names: {set(feature_names)} \n"
                f"Input column names: {expected_feature_names} \n"
                f"Please make sure that the feature names matches the columns "
                f"of the input DataFrame."
            )

    return ret_value


def build_react_series_df(react_series_response, series_index=None):
    """
    Build a DataFrame from the response from react_series.

    If series_index is set, use that
    as a name for an additional feature that will be the series index.

    Parameters
    ----------
    react_series_response : Dictionary
        The response dictionary from a call to react_series.
    series_index : String
        The name of the series index feature, which will index each series in
        the form 'series_<idx>', e.g., series_1, series_1, ..., series_n.
        If None, does not include the series index feature in the returned
        DataFrame.

    Returns
    -------
    pd.DataFrame
        A Pandas DataFrame defined by the action features and series data in
        the react_series response. Optionally includes a series index feature.
    """
    # Columns are defined by action_features
    columns = react_series_response['action_features']
    # Series data from the response
    series = react_series_response['series']

    if series_index:
        # If series_index is specified, include it as a feature
        columns.insert(0, series_index)
        # >> 'row.insert(...) or row':
        # Insert the series index into the row and append that row to
        # the 'data' list immediately. 'insert()' returns None, so 'None or row' will resolve
        # to 'row' which will, at that point, have the series index inserted.
        #
        # >> 'row for idx, sublist in enumerate(series) for row in sublist':
        # Each item (aka 'sublist') in series is a list of data rows; evaluate every row in
        # sublist for every sublist in series.
        data = [row.insert(0, f'series_{idx + 1}') or row
                for idx, sublist in enumerate(series)
                for row in sublist]
    else:
        # Else use just the data returned by react_series
        data = [row for sublist in series for row in sublist]

    return pd.DataFrame(data, columns=columns)


def date_format_is_iso(f):
    """
    Check if datetime format is ISO8601.

    Does format match the iso8601 set that can be handled by the C parser?
    Generally of form YYYY-MM-DDTHH:MM:SS - date separator can be different
    but must be consistent.  Leading 0s in dates and times are optional.

    Sourced from Pandas:
    https://github.com/pandas-dev/pandas/blob/v1.5.3/pandas/_libs/tslibs/parsing.pyx
    """
    iso_template = '%Y{date_sep}%m{date_sep}%d{time_sep}%H:%M:%S{micro_or_tz}'.format
    excluded_formats = ['%Y%m%d', '%Y%m', '%Y']

    for date_sep in [' ', '/', '\\', '-', '.', '']:
        for time_sep in [' ', 'T']:
            for micro_or_tz in ['', 'Z', '%z', '%Z', '.%f', '.%f%z', '.%f%Z', '.%fZ']:
                if (iso_template(date_sep=date_sep,
                                 time_sep=time_sep,
                                 micro_or_tz=micro_or_tz,
                                 ).startswith(f) and f not in excluded_formats):
                    return True
    return False


def deep_update(base, updates):
    """
    Update dict `base` with updates from dict `updates` in a "deep" fashion.

    NOTE: This is a recursive function. Care should be taken to ensure that
    neither of the input dictionaries are self-referencing.

    Parameters
    ----------
    base : dict
        A dictionary
    updates : dict
        A dictionary of updates

    Returns
    -------
    dict : The updated dictionary.
    """
    if all((isinstance(d, dict) for d in (base, updates))):
        for k, v in updates.items():
            base[k] = deep_update(base.get(k), v)
        return base
    return updates

howso/utilities/utilities.py
howsoai/howso-engine-py