import collections
import copy
import datetime
import functools
import json
import logging
import inspect
import operator
import os.path
import pickle
import re
import sys
import types
import typing
from urllib import parse as url_parse

import frozendict  # type: ignore
import numpy  # type: ignore
import pandas  # type: ignore
from pytypes import type_util  # type: ignore

import d3m
from . import hyperparams as hyperparams_module, primitive_names
from d3m import deprecate, exceptions, utils

# See: https://gitlab.com/datadrivendiscovery/d3m/issues/66
try:
    from pyarrow import lib as pyarrow_lib  # type: ignore
except ModuleNotFoundError:
    pyarrow_lib = None

__all__ = (
    'ALL_ELEMENTS', 'NO_VALUE', 'DataMetadata', 'PrimitiveMetadata', 'CONTAINER_SCHEMA_VERSION',
    'DATA_SCHEMA_VERSION', 'PRIMITIVE_SCHEMA_VERSION', 'PrimitiveMethodKind',
    'PrimitiveArgumentKind', 'PrimitiveInstallationType', 'PrimitiveAlgorithmType',
    'PrimitiveFamily', 'PrimitivePrecondition', 'PrimitiveEffect', 'ForeignKeyType', 'Context',
    'PipelineRunPhase', 'PipelineStepType', 'PipelineRunStatusState', 'ArgumentType',
)

logger = logging.getLogger(__name__)


@functools.total_ordering
class ALL_ELEMENTS_TYPE:
    __slots__ = ()

    def __repr__(self) -> str:
        return '__ALL_ELEMENTS__'

    def __lt__(self, other: typing.Any) -> bool:
        # "ALL_ELEMENTS" is smaller than anything else, and equal to itself.
        # "ALL_ELEMENTS" is a singleton, so is equal only if referentially equal
        # (which is a default implementation of "__eq__").
        return self != other


class NO_VALUE_TYPE:
    __slots__ = ()

    def __repr__(self) -> str:
        return '__NO_VALUE__'


ALL_ELEMENTS = ALL_ELEMENTS_TYPE()
NO_VALUE = NO_VALUE_TYPE()

COMMIT_HASH_REGEX = re.compile(r'^[0-9a-f]{40}$')

ARGUMENT_NAME_REGEX = re.compile(r'^[A-Za-z][A-Za-z_0-9]*$')

CONTAINER_SCHEMA_VERSION = 'https://metadata.datadrivendiscovery.org/schemas/v0/container.json'
DATA_SCHEMA_VERSION = 'https://metadata.datadrivendiscovery.org/schemas/v0/data.json'
PRIMITIVE_SCHEMA_VERSION = 'https://metadata.datadrivendiscovery.org/schemas/v0/primitive.json'

SCHEMAS_PATH = os.path.join(os.path.dirname(__file__), 'schemas', 'v0')

# A map of all known schemas from their URIs to loaded JSONs. Not validated.
SCHEMAS = {}
for schema_uri in [
    CONTAINER_SCHEMA_VERSION,
    DATA_SCHEMA_VERSION,
    'https://metadata.datadrivendiscovery.org/schemas/v0/definitions.json',
    'https://metadata.datadrivendiscovery.org/schemas/v0/pipeline.json',
    'https://metadata.datadrivendiscovery.org/schemas/v0/pipeline_run.json',
    PRIMITIVE_SCHEMA_VERSION,
    'https://metadata.datadrivendiscovery.org/schemas/v0/problem.json',
]:
    schema_filename = os.path.basename(schema_uri)
    with open(os.path.join(SCHEMAS_PATH, schema_filename), 'r') as schema_file:
        SCHEMAS[schema_uri] = json.load(schema_file)

DEFINITIONS_JSON = SCHEMAS['https://metadata.datadrivendiscovery.org/schemas/v0/definitions.json']

CONTAINER_SCHEMA_VALIDATOR, DATA_SCHEMA_VALIDATOR, PRIMITIVE_SCHEMA_VALIDATOR = utils.load_schema_validators(SCHEMAS, ('container.json', 'data.json', 'primitive.json'))

HYPERPARAMETER_REQUIRED_SEMANTIC_TYPES = {
    'https://metadata.datadrivendiscovery.org/types/TuningParameter',
    'https://metadata.datadrivendiscovery.org/types/ControlParameter',
    'https://metadata.datadrivendiscovery.org/types/ResourcesUseParameter',
    'https://metadata.datadrivendiscovery.org/types/MetafeatureParameter',
}

TABULAR_SEMANTIC_TYPES = {
    'https://metadata.datadrivendiscovery.org/types/Table',
    'https://metadata.datadrivendiscovery.org/types/TabularRow',
    'https://metadata.datadrivendiscovery.org/types/TabularColumn',
}

# A list of all keys which is being generated by "_generate_metadata" method.
ALL_GENERATED_KEYS = [
    'schema',
    'structural_type',
    'semantic_types',
    'dimension',
    'name',
]

PrimitiveMethodKind = utils.create_enum_from_json_schema_enum(
    'PrimitiveMethodKind', DEFINITIONS_JSON,
    'definitions.primitive_code.properties.instance_methods.additionalProperties.properties.kind.oneOf[*].enum[*]',
    module=__name__,
)
PrimitiveArgumentKind = utils.create_enum_from_json_schema_enum(
    'PrimitiveArgumentKind', DEFINITIONS_JSON,
    'definitions.primitive_code.properties.arguments.additionalProperties.properties.kind.oneOf[*].enum[*]',
    module=__name__,
)
PrimitiveInstallationType = utils.create_enum_from_json_schema_enum(
    'PrimitiveInstallationType', DEFINITIONS_JSON,
    [
        'definitions.installation.items.oneOf[*].properties.type.enum[*]',
        'definitions.installation.items.oneOf[*].allOf[*].properties.type.enum[*]'
    ],
    module=__name__,
)
PrimitiveAlgorithmType = utils.create_enum_from_json_schema_enum(
    'PrimitiveAlgorithmType', DEFINITIONS_JSON,
    'definitions.algorithm_types.items.oneOf[*].enum[*]',
    module=__name__,
)
PrimitiveFamily = utils.create_enum_from_json_schema_enum(
    'PrimitiveFamily', DEFINITIONS_JSON,
    'definitions.primitive_family.oneOf[*].enum[*]',
    module=__name__,
)
PrimitivePrecondition = utils.create_enum_from_json_schema_enum(
    'PrimitivePrecondition', DEFINITIONS_JSON,
    'definitions.preconditions.items.oneOf[*].enum[*]',
    module=__name__,
)
PrimitiveEffect = utils.create_enum_from_json_schema_enum(
    'PrimitiveEffect', DEFINITIONS_JSON,
    'definitions.effects.items.oneOf[*].enum[*]',
    module=__name__,
)
ForeignKeyType = utils.create_enum_from_json_schema_enum(
    'ForeignKeyType', DEFINITIONS_JSON,
    'definitions.foreign_key.oneOf[*].properties.type.enum[*]',
    module=__name__,
)
Context = utils.create_enum_from_json_schema_enum(
    'Context', DEFINITIONS_JSON,
    'definitions.context.oneOf[*].enum[*]',
    module=__name__,
)
PipelineRunPhase = utils.create_enum_from_json_schema_enum(
    'PipelineRunPhase', DEFINITIONS_JSON,
    'definitions.pipeline_run.properties.phase.anyOf[*].enum[*]',
    module=__name__,
)
PipelineStepType = utils.create_enum_from_json_schema_enum(
    'PipelineStepType', DEFINITIONS_JSON,
    'definitions.pipeline_steps.items.oneOf[*].properties.type.enum[*]',
    module=__name__,
)
PipelineRunStatusState = utils.create_enum_from_json_schema_enum(
    'StatusState', DEFINITIONS_JSON,
    'definitions.status.properties.state.enum[*]',
    module=__name__,
)
# Enumeration of argument and hyper-parameter types to a primitive in a step.
ArgumentType = utils.create_enum_from_json_schema_enum(
    'ArgumentType', DEFINITIONS_JSON,
    'definitions[container_argument,container_arguments,primitive_argument,primitive_arguments,data_argument,data_arguments,value_argument].properties.type.enum[*]',
    module=__name__,
)

T = typing.TypeVar('T', bound='Metadata')
D = typing.TypeVar('D', bound='DataMetadata')
P = typing.TypeVar('P', bound='PrimitiveMetadata')
SimpleSelectorSegment = typing.Union[int, str]
SelectorSegment = typing.Union[SimpleSelectorSegment, ALL_ELEMENTS_TYPE]
ListSelector = typing.List[SelectorSegment]
TupleSelector = typing.Tuple[SelectorSegment, ...]
# A list or tuple of integers, strings, or ALL_ELEMENTS.
Selector = typing.Union[ListSelector, TupleSelector]


class MetadataEntry:
    def __init__(self) -> None:
        self.elements: typing.Dict[SimpleSelectorSegment, MetadataEntry] = {}
        self.all_elements: MetadataEntry = None
        self.metadata: frozendict.FrozenOrderedDict = frozendict.FrozenOrderedDict()

    def is_empty(self) -> bool:
        return all(element.is_empty() for element in self.elements.values()) and (self.all_elements is None or self.all_elements.is_empty()) and not self.metadata


class Metadata:
    """
    A basic class to be used as a value for `metadata` attribute
    on values passed between primitives.

    Instances are immutable.

    Parameters
    ----------
    metadata : Dict[str, Any]
        Optional initial metadata for the top-level of the value.
    source : primitive or Any
        A source of initial metadata. Can be an instance of a primitive or any other relevant
        source reference. DEPRECATED: argument ignored.
    timestamp : datetime
        A timestamp of initial metadata. DEPRECATED: argument ignored.
    """

    @deprecate.arguments('source', 'timestamp')
    def __init__(self, metadata: typing.Dict[str, typing.Any] = None, *, source: typing.Any = None, timestamp: datetime.datetime = None) -> None:
        self._current_metadata = MetadataEntry()

        self._hash: int = None

        if metadata is not None:
            self._update_in_place((), metadata, self._current_metadata)

    @deprecate.arguments('source', 'timestamp')
    def update(self: T, selector: Selector, metadata: typing.Dict[str, typing.Any], *, source: typing.Any = None, timestamp: datetime.datetime = None) -> T:
        """
        Updates metadata with new ``metadata`` for data pointed to with ``selector``.

        If value of any key is ``NO_VALUE``, that key is deleted.

        It returns a copy of this metadata object with new metadata applied.

        Parameters
        ----------
        selector : Tuple[Union[str, int, ALL_ELEMENTS]
            A selector pointing to data.
        metadata : Dict
            A map of keys and values with metadata.
        source : primitive or Any
            A source of this metadata change. Can be an instance of a primitive or any other relevant
            source reference. DEPRECATED: argument ignored.
        timestamp : datetime
            A timestamp of this metadata change. DEPRECATED: argument ignored.

        Returns
        -------
        Metadata
            Updated metadata.
        """

        cls = type(self)

        new_metadata = cls()

        new_metadata._update_in_place(selector, metadata, self._current_metadata)

        return new_metadata

    @deprecate.arguments('source', 'timestamp')
    def remove(self: T, selector: Selector, *, recursive: bool = False, strict_all_elements: bool = False,
               source: typing.Any = None, timestamp: datetime.datetime = None) -> T:
        """
        Removes all metadata at ``selector``.

        Parameters
        ----------
        selector : Tuple[Union[str, int, ALL_ELEMENTS]
            A selector to remove metadata at.
        recursive : bool
            Should remove also all metadata under the ``selector``?
        strict_all_elements : bool
            If ``True``, then when removing ``ALL_ELEMENTS`` entry, do not remove also metadata for all elements it matches.
        source : primitive or Any
            A source of this metadata change. Can be an instance of a primitive or any other relevant
            source reference. DEPRECATED: argument ignored.
        timestamp : datetime
            A timestamp of this metadata change. DEPRECATED: argument ignored.

        Returns
        -------
        Metadata
            Updated metadata.
        """

        cls = type(self)

        new_metadata = cls()

        new_metadata._remove_in_place(selector, recursive, strict_all_elements, self._current_metadata)

        return new_metadata

    @deprecate.arguments('source', 'timestamp')
    def clear(self: T, metadata: typing.Dict[str, typing.Any] = None, *, source: typing.Any = None, timestamp: datetime.datetime = None) -> T:
        """
        Clears all metadata and returns a new empty (or initialized with ``metadata``) object.

        This is almost the same as creating a new metadata instance from scratch, but it keeps the link with
        the previous metadata object and preserves the history. Access to history is not yet exposed through
        an API but in the future this can help with provenance of data going through a pipeline.

        Parameters
        ----------
        metadata : Dict[str, Any]
            Optional new initial metadata for the top-level of the value.
        source : primitive or Any
            A source of this metadata change. Can be an instance of a primitive or any other relevant
            source reference. DEPRECATED: argument ignored.
        timestamp : datetime
            A timestamp of this metadata change. DEPRECATED: argument ignored.

        Returns
        -------
        Metadata
            Updated metadata.
        """

        cls = type(self)

        new_metadata = cls()

        if metadata is not None:
            new_metadata._update_in_place((), metadata, new_metadata._current_metadata)

        return new_metadata

    def _update_in_place(self, selector: Selector, metadata: typing.Dict[str, typing.Any],
                         parent_current_metadata: MetadataEntry) -> None:
        """
        This method exist only for internal purposes and you should never ever call this to update metadata from outside.
        """

        self.check_selector(selector)

        metadata = utils.make_immutable_copy(metadata)

        if not isinstance(metadata, frozendict.FrozenOrderedDict):
            raise exceptions.InvalidArgumentTypeError("Metadata should be a dict.")

        self._current_metadata = self._update(selector, parent_current_metadata, metadata)

    def _remove_in_place(self, selector: Selector, recursive: bool, strict_all_elements: bool,
                         parent_current_metadata: MetadataEntry) -> None:
        """
        This method exist only for internal purposes and you should never ever call this to remove metadata from outside.
        """

        self.check_selector(selector)

        self._current_metadata = self._remove(selector, recursive, strict_all_elements, parent_current_metadata)

    # TODO: Allow querying only a subset of metadata (not the whole dict).
    # TODO: Maybe cache results? LRU?
    def query(self, selector: Selector, *, ignore_all_elements: bool = False, remove_no_value: bool = True) -> frozendict.FrozenOrderedDict:
        """
        Returns metadata for data pointed to with ``selector``.

        When querying using ``ALL_ELEMENTS`` means only metadata which has been set using ALL_ELEMENTS
        is returned.

        Parameters
        ----------
        selector : Tuple[Union[str, int, ALL_ELEMENTS]]
            A selector to query metadata for.
        ignore_all_elements : bool
            By default, metadata from ALL_ELEMENTS is merged with metadata for an element itself.
            By setting this argument to ``True``, this is disabled and just metadata from an element is returned.
        remove_no_value : bool
            By default all ``NO_VALUE`` values are removed. If set to ``False``, they are not removed.

        Returns
        -------
        frozendict.FrozenOrderedDict
            Metadata at a given selector.
        """

        self.check_selector(selector)

        metadata = self._query(selector, self._current_metadata, 0 if ignore_all_elements else None)

        if remove_no_value:
            return self._remove_no_value(metadata)
        else:
            return metadata

    def query_with_exceptions(self, selector: Selector, *, remove_no_value: bool = True) -> typing.Tuple[frozendict.FrozenOrderedDict, typing.Dict[TupleSelector, frozendict.FrozenOrderedDict]]:
        """
        In addition to returning metadata for data pointed to with ``selector``, this method for every ``ALL_ELEMENTS``
        selector segment also returns a map between selectors and metadata for all elements which have metadata
        which differs from that of ``ALL_ELEMENTS``.

        Parameters
        ----------
        selector : Tuple[Union[str, int, ALL_ELEMENTS]]
            A selector to query metadata for.
        remove_no_value : bool
            By default all ``NO_VALUE`` values are removed. If set to ``False``, they are not removed.

        Returns
        -------
        Tuple[frozendict.FrozenOrderedDict, Dict[TupleSelector, frozendict.FrozenOrderedDict]]
            A tuple of metadata at a given selector and a dict of exceptions.
        """

        self.check_selector(selector)

        metadata = self._query(selector, self._current_metadata, None)
        if remove_no_value:
            metadata = self._remove_no_value(metadata)

        exceptions = self._query_exceptions(selector, self._current_metadata)

        exceptions_with_selectors = {}
        for exception_selector in exceptions:
            exception_metadata = self._query(exception_selector, self._current_metadata, None)
            if remove_no_value:
                exception_metadata = self._remove_no_value(exception_metadata)

            if exception_metadata and exception_metadata != metadata:
                exceptions_with_selectors[exception_selector] = exception_metadata

        return metadata, exceptions_with_selectors

    def _query(self, selector: Selector, metadata_entry: typing.Optional[MetadataEntry], ignore_all_elements: typing.Optional[int]) -> frozendict.FrozenOrderedDict:
        if metadata_entry is None:
            return frozendict.FrozenOrderedDict()
        if len(selector) == 0:
            return metadata_entry.metadata

        segment, selector_rest = selector[0], selector[1:]

        if ignore_all_elements is not None:
            new_ignore_all_elements = ignore_all_elements - 1
        else:
            new_ignore_all_elements = None

        all_elements_metadata = self._query(selector_rest, metadata_entry.all_elements, new_ignore_all_elements)
        if segment is ALL_ELEMENTS:
            metadata = all_elements_metadata
        elif segment in metadata_entry.elements:
            segment = typing.cast(SimpleSelectorSegment, segment)
            metadata = self._query(selector_rest, metadata_entry.elements[segment], new_ignore_all_elements)
            if ignore_all_elements is None or ignore_all_elements > 0:
                metadata = self._merge_metadata(all_elements_metadata, metadata)
        elif ignore_all_elements is not None and ignore_all_elements <= 0:
            metadata = frozendict.FrozenOrderedDict()
        else:
            metadata = all_elements_metadata

        return metadata

    def _query_exceptions(self, selector: Selector, metadata_entry: typing.Optional[MetadataEntry]) -> typing.Sequence[TupleSelector]:
        if metadata_entry is None:
            return []
        if len(selector) == 0:
            return []

        segment, selector_rest = selector[0], selector[1:]

        exceptions: typing.List[TupleSelector] = []
        if segment is ALL_ELEMENTS:
            if selector_rest:
                for exception_selector in self._query_exceptions(selector_rest, metadata_entry.all_elements):
                    exceptions.append((segment,) + exception_selector)

            for element_segment, element_metadata_entry in metadata_entry.elements.items():
                if selector_rest:
                    for exception_selector in self._query_exceptions(selector_rest, element_metadata_entry):
                        exceptions.append((typing.cast(SelectorSegment, element_segment),) + exception_selector)
                else:
                    if element_metadata_entry.metadata:
                        exceptions.append((element_segment,))
        elif segment in metadata_entry.elements:
            element_metadata_entry = metadata_entry.elements[typing.cast(SimpleSelectorSegment, segment)]
            if selector_rest:
                for exception_selector in self._query_exceptions(selector_rest, element_metadata_entry):
                    exceptions.append((segment,) + exception_selector)
            elif element_metadata_entry.metadata:
                exceptions.append((segment,))

        return exceptions

    def _remove(self, selector: Selector, recursive: bool, strict_all_elements: bool,
                metadata_entry: typing.Optional[MetadataEntry]) -> MetadataEntry:
        if metadata_entry is None:
            new_metadata_entry = MetadataEntry()
        else:
            new_metadata_entry = copy.copy(metadata_entry)

        if len(selector) == 0:
            new_metadata_entry.metadata = frozendict.FrozenOrderedDict()
            if recursive:
                new_metadata_entry.all_elements = None
                new_metadata_entry.elements = {}
            return new_metadata_entry

        segment, selector_rest = selector[0], selector[1:]

        if metadata_entry is not None:
            # We will be changing elements, so if we copied metadata_entry, we have to copy elements as well.
            new_metadata_entry.elements = copy.copy(new_metadata_entry.elements)

        if segment is ALL_ELEMENTS:
            new_metadata_entry.all_elements = self._remove(selector_rest, recursive, strict_all_elements, new_metadata_entry.all_elements)
            if new_metadata_entry.all_elements.is_empty():
                new_metadata_entry.all_elements = None

            if not strict_all_elements:
                for element_segment, element_metadata_entry in list(new_metadata_entry.elements.items()):
                    new_metadata_entry.elements[element_segment] = self._remove(selector_rest, recursive, strict_all_elements, element_metadata_entry)
                    if new_metadata_entry.elements[element_segment].is_empty():
                        del new_metadata_entry.elements[element_segment]

        else:
            segment = typing.cast(SimpleSelectorSegment, segment)
            if segment in new_metadata_entry.elements:
                new_metadata_entry.elements[segment] = self._remove(selector_rest, recursive, strict_all_elements, new_metadata_entry.elements[segment])
                if new_metadata_entry.elements[segment].is_empty():
                    del new_metadata_entry.elements[segment]

        return new_metadata_entry

    def _update(self, selector: Selector, metadata_entry: typing.Optional[MetadataEntry],
                metadata: frozendict.FrozenOrderedDict) -> MetadataEntry:
        if metadata_entry is None:
            new_metadata_entry = MetadataEntry()
        else:
            new_metadata_entry = copy.copy(metadata_entry)

        if len(selector) == 0:
            # One would think that we could remove "NO_VALUE" values during merging, but we have to
            # keep them to know which values we have to remove when merging with all elements metadata.
            new_metadata_entry.metadata = self._merge_metadata(new_metadata_entry.metadata, metadata)
            return new_metadata_entry

        segment, selector_rest = selector[0], selector[1:]

        if metadata_entry is not None:
            # We will be changing elements, so if we copied metadata_entry, we have to copy elements as well.
            new_metadata_entry.elements = copy.copy(new_metadata_entry.elements)

        if segment is ALL_ELEMENTS:
            new_metadata_entry.all_elements = self._update(selector_rest, new_metadata_entry.all_elements, metadata)
            if new_metadata_entry.all_elements.is_empty():
                new_metadata_entry.all_elements = None

            # Fields on direct elements have precedence over fields on ALL_ELEMENTS, but we want the last
            # call to update to take precedence. So all fields found in metadata just set on ALL_ELEMENTS
            # are removed from all metadata on direct elements.
            # We iterate over a list so that we can change dict while iterating.
            for element_segment, element_metadata_entry in list(new_metadata_entry.elements.items()):
                new_metadata_entry.elements[element_segment] = self._prune(selector_rest, element_metadata_entry, metadata)
                if new_metadata_entry.elements[element_segment] is None or new_metadata_entry.elements[element_segment].is_empty():
                    del new_metadata_entry.elements[element_segment]

        else:
            segment = typing.cast(SimpleSelectorSegment, segment)
            new_metadata_entry.elements[segment] = self._update(selector_rest, new_metadata_entry.elements.get(segment, None), metadata)
            if new_metadata_entry.elements[segment].is_empty():
                del new_metadata_entry.elements[segment]

        return new_metadata_entry

    def _merge_metadata(self, metadata1: frozendict.FrozenOrderedDict, metadata2: frozendict.FrozenOrderedDict) -> frozendict.FrozenOrderedDict:
        """
        Merges all fields from ``metadata2`` on top of ``metadata1``, recursively.

        Only dicts are merged recursively, arrays are not.
        """

        # Copy so that we can mutate.
        metadata = collections.OrderedDict(metadata1)

        for name, value in metadata2.items():
            if name in metadata:
                if isinstance(metadata[name], frozendict.FrozenOrderedDict) and isinstance(value, frozendict.FrozenOrderedDict):
                    merged_value = self._merge_metadata(metadata[name], value)
                    # If value is an empty dict, but before merging it was not, we just remove the whole field.
                    if metadata[name] and not merged_value:
                        del metadata[name]
                    else:
                        metadata[name] = merged_value
                else:
                    metadata[name] = value
            else:
                metadata[name] = value

        return frozendict.FrozenOrderedDict(metadata)

    def _remove_no_value(self, metadata: frozendict.FrozenOrderedDict) -> frozendict.FrozenOrderedDict:
        # Copy so that we can mutate.
        metadata = collections.OrderedDict(metadata)

        # We iterate over a list so that we can change dict while iterating.
        for name, value in list(metadata.items()):
            if value is NO_VALUE:
                del metadata[name]
            elif isinstance(value, frozendict.FrozenOrderedDict):
                new_value = self._remove_no_value(value)
                # If value is an empty dict, but before removing "NO_VALUE" it was not, we just remove the whole field.
                if metadata[name] and not new_value:
                    del metadata[name]
                else:
                    metadata[name] = new_value

        return frozendict.FrozenOrderedDict(metadata)

    def _prune(self, selector: Selector, metadata_entry: typing.Optional[MetadataEntry], metadata: frozendict.FrozenOrderedDict) -> typing.Optional[MetadataEntry]:
        if metadata_entry is None:
            return metadata_entry

        new_metadata_entry = copy.copy(metadata_entry)

        if len(selector) == 0:
            new_metadata_entry.metadata = self._prune_metadata(new_metadata_entry.metadata, metadata)
            return new_metadata_entry

        segment, selector_rest = selector[0], selector[1:]

        new_metadata_entry.elements = copy.copy(new_metadata_entry.elements)

        if segment is ALL_ELEMENTS:
            new_metadata_entry.all_elements = self._prune(selector_rest, new_metadata_entry.all_elements, metadata)
            if new_metadata_entry.all_elements is not None and new_metadata_entry.all_elements.is_empty():
                new_metadata_entry.all_elements = None

            # We iterate over a list so that we can change dict while iterating.
            for element_segment, element_metadata_entry in list(new_metadata_entry.elements.items()):
                new_metadata_entry.elements[element_segment] = self._prune(selector_rest, element_metadata_entry, metadata)
                if new_metadata_entry.elements[element_segment] is None or new_metadata_entry.elements[element_segment].is_empty():
                    del new_metadata_entry.elements[element_segment]

        elif segment in new_metadata_entry.elements:
            segment = typing.cast(SimpleSelectorSegment, segment)
            new_metadata_entry.elements[segment] = self._prune(selector_rest, new_metadata_entry.elements[segment], metadata)
            if new_metadata_entry.elements[segment] is None or new_metadata_entry.elements[segment].is_empty():
                del new_metadata_entry.elements[segment]

        return new_metadata_entry

    def _prune_metadata(self, metadata1: frozendict.FrozenOrderedDict, metadata2: frozendict.FrozenOrderedDict) -> frozendict.FrozenOrderedDict:
        """
        Removes all fields which are found in ``metadata2`` from ``metadata1``, recursively.

        Values of ``metadata2`` do not matter, except if they are a dict, in which case
        removal is done recursively.
        """

        # Copy so that we can mutate.
        metadata = collections.OrderedDict(metadata1)

        for name, value in metadata2.items():
            if name not in metadata:
                continue

            if isinstance(metadata[name], frozendict.FrozenOrderedDict) and isinstance(value, frozendict.FrozenOrderedDict):
                pruned_value = self._prune_metadata(metadata[name], value)
                # If value is an empty dict, but before pruning it was not, we just remove the whole field.
                if metadata[name] and not pruned_value:
                    del metadata[name]
                else:
                    metadata[name] = pruned_value
            else:
                del metadata[name]

        return frozendict.FrozenOrderedDict(metadata)

    @classmethod
    def check_selector(cls, selector: Selector) -> None:
        """
        Checks that a given ``selector`` is a valid selector. If ``selector`` is invalid it raises an exception.

        It checks that it is a tuple or a list and currently we require that all segments of a selector
        are strings, integers, or a special value ``ALL_ELEMENTS``.

        Parameters
        ----------
        selector : Tuple(str or int or ALL_ELEMENTS)
            Selector to check.
        """

        if isinstance(selector, list):
            selector = tuple(selector)
        if not isinstance(selector, tuple):
            raise exceptions.InvalidArgumentTypeError("Selector is not a tuple or a list.")

        path = []
        for segment in selector:
            path.append(segment)

            if not isinstance(segment, (str, int)) and segment is not ALL_ELEMENTS:
                raise exceptions.InvalidArgumentTypeError("'{segment}' at {path} is not a str, int, or ALL_ELEMENTS.".format(segment=segment, path=path))

    def __hash__(self) -> int:
        if self._hash is None:
            self._hash = hash(self._current_metadata)

        return self._hash

    def __eq__(self, other):  # type: ignore
        if not isinstance(other, Metadata):
            return NotImplemented

        return self._current_metadata == other._current_metadata

    def get_elements(self, selector: Selector) -> typing.Sequence[SelectorSegment]:
        """
        Returns a list of element names which exists under a selector, if any.

        Parameters
        ----------
        selector : Tuple(str or int or ALL_ELEMENTS)
            A selector to return elements under.

        Returns
        -------
        Sequence[SelectorSegment]
            List of element names.
        """

        self.check_selector(selector)

        return self._get_elements(selector, self._current_metadata)

    def _get_elements(self, selector: Selector, metadata_entry: typing.Optional[MetadataEntry]) -> typing.Sequence[SelectorSegment]:
        if metadata_entry is None:
            return []
        if len(selector) == 0:
            if metadata_entry.all_elements is not None:
                all_elements: ListSelector = [ALL_ELEMENTS]
            else:
                all_elements = []
            return all_elements + list(metadata_entry.elements.keys())

        segment, selector_rest = selector[0], selector[1:]

        all_elements_elements = self._get_elements(selector_rest, metadata_entry.all_elements)
        if segment is ALL_ELEMENTS:
            elements = all_elements_elements
        elif segment in metadata_entry.elements:
            segment = typing.cast(SimpleSelectorSegment, segment)
            elements = self._get_elements(selector_rest, metadata_entry.elements[segment])
            elements = sorted(set(typing.cast(typing.List, all_elements_elements) + typing.cast(typing.List, elements)))
        else:
            elements = all_elements_elements

        return elements

    def to_json_structure(self) -> typing.List[typing.Dict]:
        """
        Converts metadata to a JSON-compatible structure.

        The structure exposes how metadata is stored internally (metadata for ``ALL_ELEMENTS``
        separate from metadata for individual elements) and can change in the future.
        This method exist for debugging purposes and to allow serializing of metadata.
        Use `query` and `pretty_print` methods if you want to access semantically valid
        representation of metadata.

        Returns
        -------
        List[Dict]
            A JSON-compatible list of dicts.
        """

        return utils.to_json_structure(self.to_simple_structure())

    def to_simple_structure(self) -> typing.List[typing.Dict]:
        """
        Converts metadata to a simple structure, similar to JSON, but with values
        left as Python values.

        The structure exposes how metadata is stored internally (metadata for ``ALL_ELEMENTS``
        separate from metadata for individual elements) and can change in the future.
        This method exist for debugging purposes and to allow serializing of metadata.
        Use `query` and `pretty_print` methods if you want to access semantically valid
        representation of metadata.

        Returns
        -------
        List[Dict]
            A list of dicts.
        """

        return self._to_simple_structure([], self._current_metadata)

    def _to_simple_structure(self, selector: Selector, metadata_entry: typing.Optional[MetadataEntry]) -> typing.List[typing.Dict]:
        output = []

        selector = typing.cast(ListSelector, selector)

        if metadata_entry.metadata:
            output.append({
                'selector': list(selector),
                'metadata': metadata_entry.metadata,
            })

        if metadata_entry.all_elements is not None:
            output += self._to_simple_structure(selector + [ALL_ELEMENTS], metadata_entry.all_elements)

        for element_segment, element_metadata_entry in metadata_entry.elements.items():
            output += self._to_simple_structure(selector + [element_segment], element_metadata_entry)

        return output

    def pretty_print(self, selector: Selector = None, handle: typing.TextIO = None, _level: int = 0) -> None:
        """
        Pretty-prints metadata to ``handle``, or `sys.stdout` if not specified.

        The output matches the output one obtain by using `query` method and is a
        semantically valid representation of metadata, but it does not matches
        how metadata is stored internally. To obtain that, you can use `to_json_structure`
        and `to_simple_structure` methods.

        Parameters
        ----------
        selector : Tuple(str or int or ALL_ELEMENTS)
            A selector to start pretty-printing at.
        handle : TextIO
            A handle to pretty-print to. Default is `sys.stdout`.
        """

        if selector is None:
            selector = []

        if handle is None:
            handle = sys.stdout

        self.check_selector(selector)

        selector = list(selector)

        if 'selector' in inspect.signature(self.query).parameters:
            query = self.query
        else:
            def query(selector: Selector, *, ignore_all_elements: bool = False, remove_no_value: bool = True) -> frozendict.FrozenOrderedDict:
                return self.query()  # type: ignore

        indent = ' ' * _level

        handle.write('{indent}Selector:\n{indent} {selector}\n'.format(indent=indent, selector=tuple(selector)))

        handle.write('{indent}Metadata:\n'.format(indent=indent))
        for line in json.dumps(query(selector=selector), indent=1, cls=utils.JsonEncoder).splitlines():
            handle.write('{indent} {line}\n'.format(indent=indent, line=line))

        elements = self.get_elements(selector)

        if not elements:
            return

        if ALL_ELEMENTS in elements:
            handle.write('{indent}All elements:\n'.format(indent=indent))
            self.pretty_print(selector + [ALL_ELEMENTS], handle=handle, _level=_level + 1)

        first_element = True
        for element in elements:
            if element is ALL_ELEMENTS:
                continue

            if first_element:
                handle.write('{indent}Elements:\n'.format(indent=indent))
                first_element = False

            self.pretty_print(selector + [element], handle=handle, _level=_level + 1)

    def _copy_elements_metadata(self, target_metadata: T, from_selector: ListSelector,
                                to_selector: ListSelector, selector: ListSelector, ignore_all_elements: bool) -> T:
        # "ALL_ELEMENTS" is always first, if it exists, which works in our favor here.
        # We are copying metadata for both "ALL_ELEMENTS" and elements themselves, so
        # we do not have to merge metadata together for elements themselves.
        elements = self.get_elements(from_selector + selector)

        for element in elements:
            new_selector = selector + [element]
            metadata = self._query(from_selector + new_selector, self._current_metadata, 0 if ignore_all_elements else len(from_selector))
            target_metadata = target_metadata.update(to_selector + new_selector, metadata)
            target_metadata = self._copy_elements_metadata(target_metadata, from_selector, to_selector, new_selector, ignore_all_elements)

        return target_metadata

    def copy_to(self, target_metadata: T, from_selector: Selector,
                to_selector: Selector = (), *, ignore_all_elements: bool = False) -> T:
        """
        Recursively copies metadata to ``target_metadata``, starting at the
        ``from_selector`` and to a selector starting at ``to_selector``.
        """

        metadata = self._query(from_selector, self._current_metadata, 0 if ignore_all_elements else len(from_selector))

        # Do not copy top-level "schema" field to a lower level.
        if from_selector == () and to_selector != () and 'schema' in metadata:
            # Copy so that we can mutate.
            metadata_dict = collections.OrderedDict(metadata)
            del metadata_dict['schema']
            metadata = frozendict.FrozenOrderedDict(metadata_dict)

        target_metadata = target_metadata.update(to_selector, metadata)

        return self._copy_elements_metadata(target_metadata, list(from_selector), list(to_selector), [], ignore_all_elements)


class DataMetadata(Metadata):
    """
    A class for metadata for data values.

    It checks all updates against container and data schemas, and if ``for_value`` is
    set, against value itself as well.

    Parameters
    ----------
    metadata : Dict[str, Any]
        Optional initial metadata for the top-level of the value.
    for_value : Any
        Optional value associated with metadata to check updates against to
        make sure they point to data which exists.
    generate_metadata: bool
        Automatically generate metadata from ``for_value`` and update the metadata accordingly.
    check : bool
        Check if ``for_value`` matches the metadata. DEPRECATED: argument ignored.
    source : primitive or Any
        A source of initial metadata. Can be an instance of a primitive or any other relevant
        source reference. DEPRECATED: argument ignored.
    timestamp : datetime
        A timestamp of initial metadata. DEPRECATED: argument ignored.
    """

    @deprecate.arguments('source', 'timestamp', 'check')
    def __init__(self, metadata: typing.Dict[str, typing.Any] = None, for_value: typing.Any = None, *,
                 generate_metadata: bool = True, check: bool = True, source: typing.Any = None, timestamp: datetime.datetime = None) -> None:
        super().__init__(metadata=metadata)

        self._set_for_value(for_value, generate_metadata=generate_metadata)

        if metadata is not None:
            updated_metadata = self.query(selector=())

            CONTAINER_SCHEMA_VALIDATOR.validate(updated_metadata)

    @deprecate.arguments('source', 'timestamp', 'check')
    def update(self: D, selector: Selector, metadata: typing.Dict[str, typing.Any], *, for_value: typing.Any = None,
               check: bool = True, source: typing.Any = None, timestamp: datetime.datetime = None) -> D:
        """
        Updates metadata with new ``metadata`` for data pointed to with ``selector``.

        If value of any key is ``NO_VALUE``, that key is deleted.

        It returns a copy of this metadata object with new metadata applied.

        Parameters
        ----------
        selector : Tuple(str or int or ALL_ELEMENTS)
            A selector pointing to data.
        metadata : Dict
            A map of keys and values with metadata.
        for_value : Any
            Optional value associated with metadata to check updates against to
            make sure they point to data which exists.
            It replaces any previous set value associated with metadata.
        check : bool
            Check update against ``for_value``? DEPRECATED: argument ignored.
        source : primitive or Any
            A source of this metadata change. Can be an instance of a primitive or any other relevant
            source reference. DEPRECATED: argument ignored.
        timestamp : datetime
            A timestamp of this metadata change. DEPRECATED: argument ignored.

        Returns
        -------
        DataMetadata
            Updated metadata.
        """

        if for_value is None:
            for_value = self.for_value

        self.check_selector(selector)

        new_metadata = super().update(selector=selector, metadata=metadata)

        new_metadata.for_value = for_value

        updated_metadata = new_metadata.query(selector=selector)

        if len(selector) == 0:
            CONTAINER_SCHEMA_VALIDATOR.validate(updated_metadata)
        else:
            DATA_SCHEMA_VALIDATOR.validate(updated_metadata)

        return new_metadata

    @deprecate.arguments('source', 'timestamp', 'check')
    def remove(self: D, selector: Selector, *, recursive: bool = False, strict_all_elements: bool = False,
               for_value: typing.Any = None, check: bool = True, source: typing.Any = None, timestamp: datetime.datetime = None) -> D:
        """
        Removes all metadata at ``selector``.

        Parameters
        ----------
        selector : Tuple[Union[str, int, ALL_ELEMENTS]
            A selector to remove metadata at.
        recursive : bool
            Should remove also all metadata under the ``selector``?
        strict_all_elements : bool
            If ``True``, then when removing ``ALL_ELEMENTS`` entry, do not remove also metadata for all elements it matches.
        for_value : Any
            Optional value associated with metadata to check updates against to
            make sure they point to data which exists.
            It replaces any previous set value associated with metadata.
        check : bool
            Check update against ``for_value``? DEPRECATED: argument ignored.
        source : primitive or Any
            A source of this metadata change. Can be an instance of a primitive or any other relevant
            source reference. DEPRECATED: argument ignored.
        timestamp : datetime
            A timestamp of this metadata change. DEPRECATED: argument ignored.

        Returns
        -------
        DataMetadata
            Updated metadata.
        """

        if for_value is None:
            for_value = self.for_value

        self.check_selector(selector)

        new_metadata = super().remove(selector=selector, recursive=recursive, strict_all_elements=strict_all_elements)

        new_metadata.for_value = for_value

        return new_metadata

    @deprecate.arguments('source', 'timestamp', 'check')
    def set_for_value(self: D, for_value: typing.Any = None, *, generate_metadata: bool = True, check: bool = True,
                      source: typing.Any = None, timestamp: datetime.datetime = None) -> D:
        """
        Set new value associated with metadata.

        If ``generate_metadata`` is set, metadata about structure of data (dimensions) and structural types is
        extracted from the value and metadata is first updated accordingly.

        Moreover, it checks that the new value matches the metadata, by default.

        This value is used to check future updates against to make sure they point to data which exists.

        Parameters
        ----------
        for_value : Any
            New value associated with metadata.
        generate_metadata: bool
            Automatically generate metadata from ``for_value`` and update the metadata accordingly.
        check : bool
            Check if ``for_value`` matches the metadata. DEPRECATED: argument ignored.
        source : primitive or Any
            A source of metadata changes when generating metadata automatically. DEPRECATED: argument ignored.
        timestamp : datetime
            A timestamp of metadata changes when generating metadata automatically. DEPRECATED: argument ignored.

        Returns
        -------
        DataMetadata
            Metadata object with new value associated with metadata.
        """

        new_metadata = copy.copy(self)

        new_metadata._set_for_value(for_value, generate_metadata=generate_metadata)

        return new_metadata

    def _set_for_value(self, for_value: typing.Any = None, *, generate_metadata: bool = True) -> None:
        if for_value is not None:
            if generate_metadata:
                # Importing here to prevent import cycle. And to not import it many times inside "_generate_metadata".
                from d3m import container, types as d3m_types

                generated_metadata_dict = self._generate_metadata(container, d3m_types, for_value, (), True)

                # We make all metadata immutable so that it is hashable, which is required for the "_compact_generated_metadata".
                for selector, metadata in generated_metadata_dict.items():
                    generated_metadata_dict[selector] = utils.make_immutable_copy(metadata)

                # Because we generated all metadata we know that we can compact it.
                # If some metadata holds for all elements we know that we can move it to "ALL_ELEMENTS".
                generated_metadata_dict = self._compact_generated_metadata(generated_metadata_dict, ALL_GENERATED_KEYS)

                self._update_with_generated_metadata(generated_metadata_dict)

                # TODO: Also remove metadata for columns/rows which do not exist anymore.

        self.for_value = for_value

        # TODO: Add an entry to the log that value was set, logging also "generated_metadata_dict".
        #       We should be careful though that it does not make all those values then get serialized with pickle in pyarrow.

    # TODO: Should we handle inheritance between semantic types here?
    def has_semantic_type(self, selector: Selector, semantic_type: str) -> bool:
        return semantic_type in self.query(selector).get('semantic_types', ())

    @deprecate.arguments('source', 'timestamp')
    def remove_semantic_type(self: D, selector: Selector, semantic_type: str, *, source: typing.Any = None, timestamp: datetime.datetime = None) -> D:
        semantic_types = self.query(selector).get('semantic_types', ())
        if not semantic_types:
            return self
        new_semantic_types = tuple(st for st in semantic_types if st != semantic_type)
        if new_semantic_types == semantic_types:
            return self
        return self.update(selector, {'semantic_types': new_semantic_types})

    @deprecate.arguments('source', 'timestamp')
    def add_semantic_type(self: D, selector: Selector, semantic_type: str, *, source: typing.Any = None, timestamp: datetime.datetime = None) -> D:
        semantic_types = self.query(selector).get('semantic_types', ())
        if semantic_type in semantic_types:
            return self
        semantic_types += (semantic_type,)
        return self.update(selector, {'semantic_types': semantic_types})

    def get_elements_with_semantic_type(self, selector: Selector, semantic_type: str) -> typing.Sequence[SelectorSegment]:
        all_elements = self.get_elements(selector)

        return [element for element in all_elements if self.has_semantic_type(list(selector) + [element], semantic_type)]

    def query_column(self, column_index: int, *, at: Selector = (), ignore_all_elements: bool = False) -> frozendict.FrozenOrderedDict:
        """
        Returns column metadata.

        This assumes that column metadata is stored under ``(ALL_ELEMENTS, column_index)``, at
        optionally ``at`` selector.

        Parameters
        ----------
        column_index : int
            Column index to use.
        at : Selector
            Selector at which to assume tabular metadata.
        ignore_all_elements : bool
            By default, metadata from ALL_ELEMENTS is merged with metadata for an element itself.
            By setting this argument to ``True``, this is disabled and just metadata from an element is returned.

        Returns
        -------
        frozendict.FrozenOrderedDict
            Metadata of a given column.
        """

        return self.query(list(at) + [ALL_ELEMENTS, column_index], ignore_all_elements=ignore_all_elements)

    @deprecate.arguments('source', 'timestamp')
    def update_column(self: D, column_index: int, metadata: typing.Dict[str, typing.Any], *, at: Selector = (), source: typing.Any = None, timestamp: datetime.datetime = None) -> D:
        """
        Updates column metadata with new ``metadata`` for column identified by  ``column_index``.

        This assumes that column metadata is stored under ``(ALL_ELEMENTS, column_index)``, at
        optionally ``at`` selector.

        Parameters
        ----------
        column_index : int
            Column index to update.
        metadata : Dict
            A map of keys and values with metadata.
        at : Selector
            Selector at which to assume tabular metadata.
        source : primitive or Any
            A source of this metadata change. Can be an instance of a primitive or any other relevant
            source reference. DEPRECATED: argument ignored.
        timestamp : datetime
            A timestamp of this metadata change. DEPRECATED: argument ignored.

        Returns
        -------
        Metadata
            Updated column metadata.
        """

        return self.update(list(at) + [ALL_ELEMENTS, column_index], metadata)

    @deprecate.arguments('source', 'timestamp')
    def remove_column(self: D, column_index: int, *, at: Selector = (), recursive: bool = False, strict_all_elements: bool = False,
                      for_value: typing.Any = None, source: typing.Any = None, timestamp: datetime.datetime = None) -> D:
        """
        Removes all column metadata for column ``column_index``.

        This assumes that column metadata is stored under ``(ALL_ELEMENTS, column_index)``, at
        optionally ``at`` selector.

        Parameters
        ----------
        column_index : int
            Column index to remove.
        at : Selector
            Selector at which to assume tabular metadata.
        recursive : bool
            Should remove also all metadata under the ``selector``?
        strict_all_elements : bool
            If ``True``, then when removing ``ALL_ELEMENTS`` entry, do not remove also metadata for all elements it matches.
        for_value : Any
            Optional value associated with metadata to check updates against to
            make sure they point to data which exists.
            It replaces any previous set value associated with metadata.
        source : primitive or Any
            A source of this metadata change. Can be an instance of a primitive or any other relevant
            source reference. DEPRECATED: argument ignored.
        timestamp : datetime
            A timestamp of this metadata change. DEPRECATED: argument ignored.

        Returns
        -------
        Metadata
            Updated metadata.
        """

        return self.remove(
            list(at) + [ALL_ELEMENTS, column_index], recursive=recursive, strict_all_elements=strict_all_elements,
            for_value=for_value,
        )

    def get_columns_with_semantic_type(self, semantic_type: str, *, at: Selector = ()) -> typing.Sequence[SelectorSegment]:
        return self.get_elements_with_semantic_type(list(at) + [ALL_ELEMENTS], semantic_type)

    def _merge_generated_metadata(self, old_metadata: frozendict.FrozenOrderedDict, metadata: frozendict.FrozenOrderedDict) -> frozendict.FrozenOrderedDict:
        # Copy so that we can mutate.
        new_metadata = collections.OrderedDict(metadata)

        # Use generated "name" only if "name" does not already exist.
        # This holds even if existing "name" is "NO_VALUE".
        if 'name' in new_metadata and 'name' in old_metadata:
            del new_metadata['name']

        if 'name' in new_metadata.get('dimension', {}) and 'name' in old_metadata.get('dimension', {}):
            # Copy so that we can mutate.
            new_metadata['dimension'] = collections.OrderedDict(new_metadata['dimension'])
            del new_metadata['dimension']['name']
            new_metadata['dimension'] = frozendict.FrozenOrderedDict(new_metadata['dimension'])

        if 'semantic_types' in new_metadata:
            semantic_types = list(old_metadata.get('semantic_types', []))
            for semantic_type in new_metadata['semantic_types']:
                if semantic_type not in semantic_types:
                    # Only one tabular semantic type can exist at a time.
                    if semantic_type in TABULAR_SEMANTIC_TYPES:
                        semantic_types = [st for st in semantic_types if st not in TABULAR_SEMANTIC_TYPES]
                    semantic_types.append(semantic_type)
            new_metadata['semantic_types'] = tuple(semantic_types)

        if 'semantic_types' in new_metadata.get('dimension', {}):
            semantic_types = list(old_metadata.get('dimension', {}).get('semantic_types', []))
            for semantic_type in new_metadata['dimension']['semantic_types']:
                if semantic_type not in semantic_types:
                    # Only one tabular semantic type can exist at a time.
                    if semantic_type in TABULAR_SEMANTIC_TYPES:
                        semantic_types = [st for st in semantic_types if st not in TABULAR_SEMANTIC_TYPES]
                    semantic_types.append(semantic_type)
            # Copy so that we can mutate.
            new_metadata['dimension'] = collections.OrderedDict(new_metadata['dimension'])
            new_metadata['dimension']['semantic_types'] = tuple(semantic_types)
            new_metadata['dimension'] = frozendict.FrozenOrderedDict(new_metadata['dimension'])

        # If structural type was not generated now, but it exists before, we have to remove it.
        # Here we just delete it from "old_metadata" so that it is not re-set back, while
        # we really handle it in "_update_with_generated_metadata".
        if 'structural_type' not in new_metadata and 'structural_type' in old_metadata:
            # Copy so that we can mutate.
            old_metadata_dict = collections.OrderedDict(old_metadata)
            del old_metadata_dict['structural_type']
            old_metadata = frozendict.FrozenOrderedDict(old_metadata_dict)

        return self._merge_metadata(old_metadata, frozendict.FrozenOrderedDict(new_metadata))

    def _diff_generated_metadata(self, element_metadata: frozendict.FrozenOrderedDict, metadata: frozendict.FrozenOrderedDict) -> frozendict.FrozenOrderedDict:
        """
        When preparing updates for automatically generated metadata we want to make sure we do not override any metadata
        directly set on elements with metadata on ``ALL_ELEMENTS``. In this method we compute which metadata to update
        after the automatically generated metadata is set for ``ALL_ELEMENTS`` to restore the metadata directly set
        on elements.
        """

        # Copy so that we can mutate.
        new_element_metadata = collections.OrderedDict(element_metadata)

        # No need to set name if it is equal to metadata on "ALL_ELEMENTS".
        if 'name' in new_element_metadata and 'name' in metadata and new_element_metadata['name'] == metadata['name']:
            del new_element_metadata['name']

        # No need to set name if it is equal to metadata on "ALL_ELEMENTS".
        if 'name' in new_element_metadata.get('dimension', {}) and 'name' in metadata.get('dimension', {}) and new_element_metadata['dimension']['name'] == metadata['dimension']['name']:
            # Copy so that we can mutate.
            new_element_metadata['dimension'] = collections.OrderedDict(new_element_metadata['dimension'])
            del new_element_metadata['dimension']['name']
            new_element_metadata['dimension'] = frozendict.FrozenOrderedDict(new_element_metadata['dimension'])

        if 'semantic_types' in new_element_metadata and 'semantic_types' in metadata:
            # No need to merge semantic types if they are equal to metadata on "ALL_ELEMENTS".
            if set(new_element_metadata['semantic_types']) == set(metadata['semantic_types']):
                del new_element_metadata['semantic_types']
            else:
                semantic_types = list(new_element_metadata['semantic_types'])
                for semantic_type in metadata['semantic_types']:
                    if semantic_type not in semantic_types:
                        # Only one tabular semantic type can exist at a time.
                        if semantic_type in TABULAR_SEMANTIC_TYPES:
                            semantic_types = [st for st in semantic_types if st not in TABULAR_SEMANTIC_TYPES]
                        semantic_types.append(semantic_type)
                new_element_metadata['semantic_types'] = tuple(semantic_types)

        if 'semantic_types' in new_element_metadata.get('dimension', {}) and 'semantic_types' in metadata.get('dimension', {}):
            # No need to merge semantic types if they are equal to metadata on "ALL_ELEMENTS".
            if set(new_element_metadata['dimension']['semantic_types']) == set(metadata['dimension']['semantic_types']):
                new_element_metadata['dimension'] = collections.OrderedDict(new_element_metadata['dimension'])
                del new_element_metadata['dimension']['semantic_types']
                new_element_metadata['dimension'] = frozendict.FrozenOrderedDict(new_element_metadata['dimension'])
            else:
                semantic_types = list(new_element_metadata['dimension']['semantic_types'])
                for semantic_type in metadata['dimension']['semantic_types']:
                    if semantic_type not in semantic_types:
                        # Only one tabular semantic type can exist at a time.
                        if semantic_type in TABULAR_SEMANTIC_TYPES:
                            semantic_types = [st for st in semantic_types if st not in TABULAR_SEMANTIC_TYPES]
                        semantic_types.append(semantic_type)
                # Copy so that we can mutate.
                new_element_metadata['dimension'] = collections.OrderedDict(new_element_metadata['dimension'])
                new_element_metadata['dimension']['semantic_types'] = tuple(semantic_types)
                new_element_metadata['dimension'] = frozendict.FrozenOrderedDict(new_element_metadata['dimension'])

        # Structural type is always set or removed by generated metadata, so it should not be directly set on elements.
        if 'structural_type' in new_element_metadata:
            del new_element_metadata['structural_type']

        for generated_key in ALL_GENERATED_KEYS:
            # We already processed these.
            if generated_key in {'name', 'dimension', 'semantic_types', 'structural_type'}:
                continue

            # No need to set this field if it is equal to metadata on "ALL_ELEMENTS".
            if generated_key in new_element_metadata and generated_key in metadata and new_element_metadata[generated_key] == metadata[generated_key]:
                del new_element_metadata[generated_key]

        # We iterate over a list so that we can change dict while iterating.
        for key in list(new_element_metadata.keys()):
            # We already processed these.
            if key in ALL_GENERATED_KEYS:
                continue

            # Other keys are never generated, so they are never overridden, so no need to set them again.
            del new_element_metadata[key]

        if 'dimension' in new_element_metadata:
            # Copy so that we can mutate.
            new_element_metadata['dimension'] = collections.OrderedDict(new_element_metadata['dimension'])

            # Length is always set by generated metadata, so it should not be directly set on elements.
            if 'length' in new_element_metadata['dimension']:
                del new_element_metadata['dimension']['length']

            # We iterate over a list so that we can change dict while iterating.
            for key in list(new_element_metadata['dimension'].keys()):
                # We already processed these.
                if key in {'name', 'semantic_types'}:
                    continue

                # Other keys are never generated, so they are never overridden, so no need to set them again.
                del new_element_metadata['dimension'][key]

            new_element_metadata['dimension'] = frozendict.FrozenOrderedDict(new_element_metadata['dimension'])

        return frozendict.FrozenOrderedDict(new_element_metadata)

    @classmethod
    def _generate_metadata(cls: typing.Type[D], container: types.ModuleType, d3m_types: types.ModuleType, value: typing.Any,
                           selector: TupleSelector, is_root: bool = False) -> typing.Dict[TupleSelector, typing.Dict]:
        """
        Returned metadata should be additionally compacted before use.

        We make sure that the first element of the returned dict is the entry which corresponds to the ``selector``.

        Important: Any top-level key set by this method should be listed in ``ALL_GENERATED_KEYS``.
        """

        generated_metadata: dict = {}

        if is_root:
            generated_metadata['schema'] = CONTAINER_SCHEMA_VERSION

        # We use a simple type here, not "utils.get_type" because it is faster and also because we anyway
        # traverse the data structure ourselves and store nested typing information ourselves into metadata.
        generated_metadata['structural_type'] = type(value)

        # TODO: Traverse structure also for Graph objects.
        # Fast path. We first check if the value is of a simple data type.
        if isinstance(value, d3m_types.simple_data_types):  # type: ignore
            # We just store structural type of the value (already present in "generated_metadata").
            return collections.OrderedDict([(selector, generated_metadata)])

        if isinstance(value, container.List):  # type: ignore
            generated_metadata['dimension'] = {
                'length': len(value),
            }

            metadata_dict = collections.OrderedDict([(selector, generated_metadata)])

            metadata_dict_list: typing.List[typing.Dict[TupleSelector, typing.Dict]] = []
            for v in value:
                # We recurse with selector set to "()"so that it is easier to compare results for equality.
                metadata_dict_list.append(cls._generate_metadata(container, d3m_types, v, ()))

            if metadata_dict_list:
                # Equality of "OrderedDict" also checks for the equality in order of keys.
                if all(element_dict == metadata_dict_list[0] for element_dict in metadata_dict_list):
                    selector_all_elements = selector + (ALL_ELEMENTS,)

                    # All elements are equal, so we use the first element.
                    for element_selector, element_metadata in metadata_dict_list[0].items():
                        # We recursed with selector set to "()" so we have to adapt the real selector now.
                        new_selector = selector_all_elements + element_selector
                        assert new_selector not in metadata_dict
                        metadata_dict[new_selector] = element_metadata

                else:
                    for element_index, element_dict in enumerate(metadata_dict_list):
                        for element_selector, element_metadata in element_dict.items():
                            # We recursed with selector set to "()" so we have to adapt the real selector now.
                            new_selector = selector + (element_index,) + element_selector
                            assert new_selector not in metadata_dict
                            metadata_dict[new_selector] = element_metadata

            return metadata_dict

        if isinstance(value, container.Dataset):  # type: ignore
            generated_metadata['dimension'] = {
                'name': 'resources',
                'semantic_types': ['https://metadata.datadrivendiscovery.org/types/DatasetResource'],
                'length': len(value),
            }

            metadata_dict = collections.OrderedDict([(selector, generated_metadata)])

            for k, v in value.items():
                if not isinstance(k, str):
                    raise TypeError("Dataset resource ID has to be a string, not: {k_type}".format(k_type=type(k)))
                metadata_dict.update(cls._generate_metadata(container, d3m_types, v, selector + (k,)))

            # It is unlikely that metadata is equal across dataset resources, so we do not try to compact metadata here.

            return metadata_dict

        if isinstance(value, container.DataFrame):  # type: ignore
            if len(value.shape) != 2:
                raise ValueError("Only two-dimensional DataFrames are supported, at {selector}.".format(selector=selector))

            generated_metadata['semantic_types'] = ['https://metadata.datadrivendiscovery.org/types/Table']

            generated_metadata['dimension'] = {
                'name': 'rows',
                'semantic_types': ['https://metadata.datadrivendiscovery.org/types/TabularRow'],
                'length': value.shape[0],
            }

            metadata_dict = collections.OrderedDict([(selector, generated_metadata)])

            # Reusing the variable for next dimension.
            generated_metadata = {
                'dimension': {
                    'name': 'columns',
                    'semantic_types': ['https://metadata.datadrivendiscovery.org/types/TabularColumn'],
                    'length': value.shape[1],
                },
            }

            selector_all_rows = selector + (ALL_ELEMENTS,)
            metadata_dict[selector_all_rows] = generated_metadata

            for column_index, dtype in enumerate(value.dtypes):
                column_metadata = {}

                # Only if a column name is a string. DataFrame can have a sequence/numbers for column names
                # but those are generally automatically generated so we do not use them as column names here.
                if isinstance(value.columns[column_index], str):
                    # We set the name first, so that recursive calls to "_generate_metadata" can potentially
                    # override it. "_generate_metadata" does not do it for now, but it could do it in the future.
                    # Generated names to not override names if they already exists in metadata, which is
                    # handled in the "_update_with_generated_metadata" method.
                    column_metadata['name'] = value.columns[column_index]

                selector_all_rows_column = selector_all_rows + (column_index,)

                # Values are objects. This could be something as simple as a Python string, or a whole other container value nested.
                if dtype.hasobject:
                    metadata_column_dict_list: typing.List[typing.Dict[TupleSelector, dict]] = []
                    for row_index, cell_value in enumerate(value.iloc[:, column_index]):
                        # We recurse with selector set to "()"so that it is easier to compare results for equality.
                        metadata_column_dict_list.append(cls._generate_metadata(container, d3m_types, cell_value, ()))

                    if metadata_column_dict_list:
                        # Equality of "OrderedDict" also checks for the equality in order of keys.
                        if all(row_dict == metadata_column_dict_list[0] for row_dict in metadata_column_dict_list):
                            # All rows are equal, so we use the first row.
                            for row_selector, row_metadata in metadata_column_dict_list[0].items():
                                # We recursed with selector set to "()" so we have to adapt the real selector now.
                                new_selector = selector_all_rows_column + row_selector
                                if new_selector == selector_all_rows_column:
                                    row_metadata.update(column_metadata)
                                assert new_selector not in metadata_dict
                                metadata_dict[new_selector] = row_metadata

                        else:
                            metadata_dict[selector_all_rows_column] = column_metadata

                            for row_index, row_dict in enumerate(metadata_column_dict_list):
                                for row_selector, row_metadata in row_dict.items():
                                    # We recursed with selector set to "()" so we have to adapt the real selector now.
                                    new_selector = selector + (row_index, column_index) + row_selector
                                    assert new_selector not in metadata_dict
                                    metadata_dict[new_selector] = row_metadata

                    else:
                        metadata_dict[selector_all_rows_column] = column_metadata

                else:
                    # DataFrame is trying to be smart and returns sometimes Python types instead
                    # of numpy types when retrieving values from it. On the other hand, dtypes are
                    # generally numpy types. So there can be discrepancy between recorded structural
                    # type in metadata and what you get for some operations out of a DataFrame.
                    # See: https://github.com/pandas-dev/pandas/issues/20791
                    #      https://github.com/pandas-dev/pandas/issues/13468
                    column_metadata['structural_type'] = dtype.type
                    metadata_dict[selector_all_rows_column] = column_metadata

            return metadata_dict

        # This also handles "container.matrix".
        if isinstance(value, container.ndarray):  # type: ignore
            if not value.shape:
                raise ValueError("Zero-dimensional arrays are not supported, at {selector}.".format(selector=selector))

            metadata_dict = collections.OrderedDict()

            for dimension_index, dimension_length in enumerate(value.shape):
                generated_metadata['dimension'] = {
                    'length': dimension_length,
                }

                if len(value.shape) == 2:
                    if dimension_index == 0:
                        generated_metadata['semantic_types'] = ['https://metadata.datadrivendiscovery.org/types/Table']
                        generated_metadata['dimension']['name'] = 'rows'
                        generated_metadata['dimension']['semantic_types'] = ['https://metadata.datadrivendiscovery.org/types/TabularRow']
                    elif dimension_index == 1:
                        generated_metadata['dimension']['name'] = 'columns'
                        generated_metadata['dimension']['semantic_types'] = ['https://metadata.datadrivendiscovery.org/types/TabularColumn']

                metadata_dict[selector + (ALL_ELEMENTS,) * dimension_index] = generated_metadata

                # Reusing the variable for next dimension.
                generated_metadata = {}

            if value.dtype.hasobject:
                metadata_cell_dict_list: typing.List[typing.Dict[TupleSelector, typing.Dict]] = []
                metadata_cell_indices: typing.List[typing.Tuple] = []

                iterator = numpy.nditer(value, flags=['multi_index', 'refs_ok'])
                while not iterator.finished:
                    # We recurse with selector set to "()"so that it is easier to compare results for equality.
                    metadata_cell_dict_list.append(cls._generate_metadata(container, d3m_types, iterator.value.item(), ()))
                    metadata_cell_indices.append(tuple(iterator.multi_index))
                    iterator.iternext()

                if metadata_cell_dict_list:
                    # Equality of "OrderedDict" also checks for the equality in order of keys.
                    if all(cell_dict == metadata_cell_dict_list[0] for cell_dict in metadata_cell_dict_list):
                        selector_all_cells = selector + (ALL_ELEMENTS,) * len(value.shape)

                        # All cells are equal, so we use the first cell.
                        for cell_selector, cell_metadata in metadata_cell_dict_list[0].items():
                            # We recursed with selector set to "()" so we have to adapt the real selector now.
                            new_selector = selector_all_cells + cell_selector
                            assert new_selector not in metadata_dict
                            metadata_dict[new_selector] = cell_metadata

                    else:
                        for cell_index, cell_dict in zip(metadata_cell_indices, metadata_cell_dict_list):
                            for cell_selector, cell_metadata in cell_dict.items():
                                # We recursed with selector set to "()" so we have to adapt the real selector now.
                                new_selector = selector + cell_index + cell_selector
                                assert new_selector not in metadata_dict
                                metadata_dict[new_selector] = cell_metadata

            else:
                metadata_dict[selector + (ALL_ELEMENTS,) * len(value.shape)] = {'structural_type': value.dtype.type}

            return metadata_dict

        # We went through all container types and none matched.
        if is_root:
            assert not isinstance(value, d3m_types.Container), type(value)  # type: ignore
            raise TypeError("Value is not of a container type, but '{type}'.".format(type=type(value)))

        # A special case for dicts, for which we traverse the structure.
        if isinstance(value, dict):
            generated_metadata['dimension'] = {
                'length': len(value),
            }

            metadata_dict = collections.OrderedDict([(selector, generated_metadata)])

            metadata_dict_list = []
            metadata_indices: typing.List[typing.Tuple] = []
            for k, v in value.items():
                if not isinstance(k, (str, int)):
                    raise TypeError("Dict key has to be a string or an integer, not: {k_type}".format(k_type=type(k)))
                # We recurse with selector set to "()"so that it is easier to compare results for equality.
                metadata_dict_list.append(cls._generate_metadata(container, d3m_types, v, ()))
                metadata_indices.append(k)

            if metadata_dict_list:
                # Equality of "OrderedDict" also checks for the equality in order of keys.
                if all(element_dict == metadata_dict_list[0] for element_dict in metadata_dict_list):
                    selector_all_elements = selector + (ALL_ELEMENTS,)

                    # All elements are equal, so we use the first element.
                    for element_selector, element_metadata in metadata_dict_list[0].items():
                        # We recursed with selector set to "()" so we have to adapt the real selector now.
                        new_selector = selector_all_elements + element_selector
                        assert new_selector not in metadata_dict
                        metadata_dict[new_selector] = element_metadata

                else:
                    for element_index, element_dict in zip(metadata_indices, metadata_dict_list):
                        for element_selector, element_metadata in element_dict.items():
                            # We recursed with selector set to "()" so we have to adapt the real selector now.
                            new_selector = selector + (element_index,) + element_selector
                            assert new_selector not in metadata_dict
                            metadata_dict[new_selector] = element_metadata

            return metadata_dict

        # We checked for all simple data types, container types, and a dict. Nothing else is left.
        assert not isinstance(value, d3m_types.Data)  # type: ignore
        raise TypeError("Value is not of a data type, but '{type}'.".format(type=type(value)))

    # TODO: During compacting, we could also create an Union type of all structural types in elements and set it on "ALL_ELEMENTS".
    @classmethod
    def _compact_generated_metadata(cls: typing.Type[D], metadata_dict: typing.Dict[TupleSelector, typing.Dict], keys_to_compact: typing.List[str]) -> typing.Dict[TupleSelector, typing.Dict]:
        """
        Compacts only top-level keys (if their values are all equal) listed in ``keys_to_compact``.

        Metadata should contain only top-level keys listed in ``keys_to_compact``. The reason for ``keys_to_compact``
        is that it is an optimization, so that we do not have to first go over all metadata to detect which all
        keys are there. Because ``_generate_metadata`` is producing a fixed set of keys this works for us.

        We prefer to compact segments at the beginning of the selector over the segments later on.

        Parameters
        ----------
        metadata_dict: Dict[TupleSelector, Dict]
            A dict where key is selector and value is the metadata dict under this selector.
        keys_to_compact : List[str]
            Which keys to compact in the metadata.

        Returns
        -------
        Dict[TupleSelector, dict]
            Compacted metadata representation in the form of a dict where keys are selectors.
        """

        # We rely on the fact that dicts preserve order in Python 3.6+ and do not use
        # "OrderedDict" here for simplicity (we do not compare by equality dicts here to care
        # about order of keys in equality check).
        results: typing.Dict[TupleSelector, typing.Dict] = collections.defaultdict(dict)

        # Key is the length of selectors and the value is a list of selectors of the same length.
        selector_lengths: typing.Dict[int, typing.List[TupleSelector]] = collections.defaultdict(list)
        for selector in metadata_dict.keys():
            selector_lengths[len(selector)].append(selector)

        for length, selectors in sorted(selector_lengths.items(), key=operator.itemgetter(0)):
            update_selectors: typing.Dict[TupleSelector, typing.List] = collections.defaultdict(list)

            for key in keys_to_compact:
                values_to_selectors: typing.Dict[typing.Any, typing.List[TupleSelector]] = collections.defaultdict(list)
                for selector in selectors:
                    value = metadata_dict[selector].get(key, None)
                    if value is not None:
                        values_to_selectors[value].append(selector)

                for value in values_to_selectors.keys():
                    compacted_selectors = cls._get_compacted_selectors(values_to_selectors[value], selectors)

                    for selector in compacted_selectors:
                        update_selectors[selector].append({key: value})

            for selector, items in sorted(update_selectors.items(), key=operator.itemgetter(0)):
                for item in items:
                    results[selector].update(item)

        return collections.OrderedDict(results)

    @classmethod
    def _get_compacted_selectors(cls, selectors_to_compact: typing.List[TupleSelector], total_selectors: typing.List[TupleSelector]) -> typing.List[TupleSelector]:
        """
        This function returns a compacted representation of ``selectors_to_compact``.

        Parameters
        ----------
        selectors_to_compact : List[TupleSelector]
            A list of selectors to be compacted which have the same value under a certain key.
        total_selectors : List[TupleSelector]
            All possible selectors of a certain length.

        Returns
        -------
        List[TupleSelector]
            A list of compacted selectors.
        """

        input_selectors = copy.copy(selectors_to_compact)
        input_selectors_set = set(input_selectors)
        output_selectors = selectors_to_compact

        length_of_selector = len(input_selectors[0])

        other_selectors_set = set(total_selectors) - input_selectors_set

        for other_selector in sorted(other_selectors_set):
            if cls._selector_overlap(other_selector, input_selectors_set):
                other_selectors_set.remove(other_selector)

        for i in range(length_of_selector):
            all_segments = {selector[i] for selector in total_selectors}
            for index, selector_tuple in enumerate(output_selectors):
                can_collapse = True

                for segment in all_segments:
                    test_selector = list(selector_tuple)
                    test_selector[i] = segment
                    if cls._selector_overlap(test_selector, other_selectors_set):
                        can_collapse = False

                if can_collapse:
                    selector_list = list(selector_tuple)
                    selector_list[i] = ALL_ELEMENTS
                    output_selectors[index] = tuple(selector_list)

            output_selectors = sorted(set(output_selectors))

        output_selectors = cls._greedy_prune_selector(output_selectors, input_selectors)

        return output_selectors

    @classmethod
    def _selector_overlap(cls, test_selector: Selector, selectors_set: typing.Set[TupleSelector]) -> bool:
        """
        This function checks if ``test_selector`` overlaps with selectors ``selectors_set``.

        Parameters
        ----------
        test_selector : Selector
            The input selector.
        selectors_set : Set[TupleSelector]
            A set of selectors.

        Returns
        -------
        bool
            Whether the selector ``test_selector`` overlaps with any selector in ``selector_list``.
        """

        for selector in selectors_set:
            assert len(selector) == len(test_selector)

            is_same = True
            for i in range(len(test_selector)):
                if test_selector[i] is ALL_ELEMENTS:
                    continue
                if selector[i] is not ALL_ELEMENTS:
                    if test_selector[i] != selector[i]:
                        is_same = False

            if is_same:
                return True

        return False

    @classmethod
    def _selector_contained(cls, selector_1: Selector, selector_2: Selector) -> bool:
        """
        This function checks if ``selector_1`` is contained in ``selector_2``.

        Returns
        -------
        bool
            Whether ``selector_1`` is contained in ``selector_2``.

        Notes
        -----
        This function is different from `_selector_overlap` which checks if two selectors overlap.
        """

        for i in range(len(selector_1)):
            if selector_1[i] is ALL_ELEMENTS:
                if selector_2[i] is not ALL_ELEMENTS:
                    return False
                continue
            if selector_2[i] is not ALL_ELEMENTS:
                if selector_1[i] != selector_2[i]:
                    return False

        return True

    @classmethod
    def _greedy_prune_selector(cls, compacted_selectors: typing.List[TupleSelector], selectors_to_compact: typing.List[TupleSelector]) -> typing.List[TupleSelector]:
        """
        This method implements a greedy algorithm to remove unnecessary selectors from ``compacted_selectors``.

        Parameters
        ----------
        compacted_selectors : List[TupleSelector]
            This is an already compacted list of selectors which we get from ``selectors_to_compact``.
        selectors_to_compact : List[TupleSelector]
            This is the list of original selectors with the same value under a certain key.

        Returns
        -------
        List[TupleSelector]
            The list of selectors where unnecessary selectors have been removed from ``compacted_selectors``.
        """

        # Maps from each selector in "compacted_selectors" to selectors which it covers in "selectors_to_compact".
        contained_selectors: typing.Dict[TupleSelector, typing.List[TupleSelector]] = collections.defaultdict(list)
        selector_count_mask: typing.Dict[TupleSelector, int] = collections.defaultdict(int)

        # Compute for each selector in "selectors_to_compact" how many selectors in "compacted_selectors" cover them.
        # Also builds the "contained_selectors".
        for compact_selector in compacted_selectors:
            for selector in selectors_to_compact:
                if cls._selector_contained(selector, compact_selector):
                    selector_count_mask[selector] += 1
                    contained_selectors[compact_selector].append(selector)

        continue_flag = True
        while continue_flag:
            continue_flag = False
            for compact_selector in compacted_selectors:
                remove_flag = True
                for selector in contained_selectors[compact_selector]:
                    if selector_count_mask[selector] == 1:
                        remove_flag = False
                if remove_flag:
                    continue_flag = True
                    redundant_selector = compact_selector
            if continue_flag:
                compacted_selectors.remove(redundant_selector)
                for selector in contained_selectors[redundant_selector]:
                    selector_count_mask[selector] -= 1

        return compacted_selectors

    def _update_with_generated_metadata(self, generated_metadata_dict: typing.Dict[TupleSelector, dict]) -> None:
        """
        This method works well really just with generated metadata. It has some assumptions what ``generated_metadata_dict``
        contains and how to merge things (merge semantic types, do not override names, clear unset structural types).
        """

        # We first preprocess given updates. We have to specially merge some fields and respect overrides
        # on direct elements.
        updates: typing.List[typing.Tuple[TupleSelector, dict]] = []
        for selector, metadata in generated_metadata_dict.items():
            existing_metadata, metadata_exceptions = self.query_with_exceptions(selector, remove_no_value=False)

            # If structural type was not generated now, but it exists before, we have to remove it. In "_merge_generated_metadata" we make sure
            # it is not re-set back, and here we add an update at the beginning which removes it. The reason why it is at the beginning is that
            # it could be that the reason why there is no "structural_type" in "metadata" is because it was moved to metadata for corresponding
            # "ALL_ELEMENTS". So, the order is then: we remove it through direct selector, then maye "ALL_ELEMENTS" selector re-sets it back,
            # and merged metadata does not re-set it, because we made sure about that in "_merge_generated_metadata".
            if 'structural_type' not in metadata and 'structural_type' in existing_metadata:
                updates.insert(0, (selector, {'structural_type': NO_VALUE}))

            metadata = self._merge_generated_metadata(existing_metadata, metadata)

            updates.append((selector, metadata))

            for exception_selector, exception_metadata in metadata_exceptions.items():
                diff_metadata = self._diff_generated_metadata(exception_metadata, metadata)

                updates.append((exception_selector, diff_metadata))

        for selector, metadata in updates:
            metadata = utils.make_immutable_copy(metadata)

            if not isinstance(metadata, frozendict.FrozenOrderedDict):
                raise exceptions.InvalidArgumentTypeError("Metadata should be a dict.")

            self._current_metadata = self._update(selector, self._current_metadata, metadata)

    @deprecate.arguments('source', 'timestamp', 'check')
    def clear(self: D, metadata: typing.Dict[str, typing.Any] = None, *, for_value: typing.Any = None,
              generate_metadata: bool = True, check: bool = True, source: typing.Any = None, timestamp: datetime.datetime = None) -> D:
        """
        Clears all metadata and returns a new empty (or initialized with ``metadata``) object.

        This is almost the same as creating a new metadata instance from scratch, but it keeps the link with
        the previous metadata object and preserves the history. Access to history is not yet exposed through
        an API but in the future this can help with provenance of data going through a pipeline.

        Parameters
        ----------
        metadata : Dict[str, Any]
            Optional new initial metadata for the top-level of the value.
        for_value : Any
            Optional value associated with metadata to check updates against to
            make sure they point to data which exists.
        generate_metadata: bool
            Automatically generate metadata from the ``for_value`` and update the metadata accordingly.
        check : bool
            Check if ``for_value`` matches the metadata. DEPRECATED: argument ignored.
        source : primitive or Any
            A source of this metadata change. Can be an instance of a primitive or any other relevant
            source reference. DEPRECATED: argument ignored.
        timestamp : datetime
            A timestamp of this metadata change. DEPRECATED: argument ignored.

        Returns
        -------
        DataMetadata
            Updated metadata.
        """

        if for_value is None:
            for_value = self.for_value

        new_metadata = super().clear(metadata=metadata)

        new_metadata._set_for_value(for_value, generate_metadata=generate_metadata)

        if metadata is not None:
            updated_metadata = new_metadata.query(selector=())

            CONTAINER_SCHEMA_VALIDATOR.validate(updated_metadata)

        return new_metadata

    def check(self, for_value: typing.Any) -> None:
        """
        Checks that all metadata has a corresponding data in ``for_value``.
        If not it raises an exception.

        Parameters
        ----------
        for_value : Any
            Value to check against.
        """

        self._check(self._current_metadata, for_value, [])

    # TODO: Check if structural types match the real type of a value.
    @classmethod
    def _check(cls, metadata_entry: MetadataEntry, for_value: typing.Any, path: typing.List[SimpleSelectorSegment]) -> None:
        if metadata_entry.all_elements is not None:
            try:
                # We should be able to at least compute length at this dimension
                # (to signal that it is a sequence or a map).
                len(for_value)
            except Exception as error:
                raise ValueError("ALL_ELEMENTS set but dimension missing at {path}.".format(path=path)) from error

        if isinstance(for_value, numpy.matrix):
            # One cannot iterate over a matrix segment by segment. You always get back
            # a matrix (2D structure) and not an array of rows or columns. By converting
            # it to an array such iteration segment by segment works.
            for_value = numpy.array(for_value)

        if isinstance(for_value, pandas.DataFrame):
            for element_segment, element_metadata_entry in metadata_entry.elements.items():
                try:
                    # Fetch a row as a list.
                    element_value = [for_value.iloc[element_segment, k] for k in range(len(for_value.columns))]
                except Exception as error:
                    raise ValueError("'{element_segment}' at {path} cannot be resolved.".format(element_segment=element_segment, path=path)) from error

                cls._check(element_metadata_entry, element_value, path + [element_segment])

        else:
            for element_segment, element_metadata_entry in metadata_entry.elements.items():
                try:
                    element_value = for_value[element_segment]
                except Exception as error:
                    raise ValueError("'{element_segment}' at {path} cannot be resolved.".format(element_segment=element_segment, path=path)) from error

                cls._check(element_metadata_entry, element_value, path + [element_segment])

    @classmethod
    @deprecate.arguments('for_value')
    def check_selector(cls, selector: Selector, for_value: typing.Any = None) -> None:
        """
        Checks that a given ``selector`` is a valid selector. If ``selector`` is invalid it raises an exception.

        It checks that it is a tuple or a list and currently we require that all segments of a selector
        are strings, integers, or a special value ``ALL_ELEMENTS``.

        If ``for_value`` is provided, it also tries to resolve the ``selector`` against the value
        to assure that the selector is really pointing to data which exists.

        Parameters
        ----------
        selector : Tuple(str or int or ALL_ELEMENTS)
            Selector to check.
        for_value : Any
            Value to check against. DEPRECATED: argument ignored.
        """

        super().check_selector(selector=selector)


class PrimitiveMetadata(Metadata):
    """
    A class for metadata for primitives.

    It checks all updates against primitive schema.
    """

    def __init__(self, metadata: typing.Dict[str, typing.Any] = None) -> None:
        super().__init__(metadata=metadata)

        # We do not do validation here because provided metadata on its own is
        # probably not sufficient for validation to pass. Validation happens
        # inside "contribute_to_class" method instead.

        # Importing here to prevent import cycle.
        from d3m.primitive_interfaces import base

        self.primitive: typing.Type[base.PrimitiveBase] = None

    # Not adhering to Liskov substitution principle: we do not have "selector" argument.
    @deprecate.arguments('source', 'timestamp')
    def update(self: P, metadata: typing.Dict[str, typing.Any], *, source: typing.Any = None, timestamp: datetime.datetime = None) -> P:  # type: ignore
        new_metadata = super().update(selector=(), metadata=metadata)

        self._validate()

        return new_metadata

    @deprecate.arguments('source', 'timestamp')
    def clear(self: P, metadata: typing.Dict[str, typing.Any] = None, *, source: typing.Any = None, timestamp: datetime.datetime = None) -> P:
        new_metadata = super().clear(metadata=metadata)

        new_metadata.primitive = self.primitive

        new_metadata._generate_and_update()

        return new_metadata

    # Not adhering to Liskov substitution principle: we do not have "selector" argument.
    def query(self) -> frozendict.FrozenOrderedDict:  # type: ignore
        return super().query(selector=())

    # "primitive" should be of PrimitiveBase here, but we do not want to introduce a
    # cyclic dependency. We validate the type at runtime in the method.
    def contribute_to_class(self: P, primitive: typing.Any) -> None:
        # Importing here to prevent import cycle.
        from d3m.primitive_interfaces import base

        if self.primitive is not None:
            raise exceptions.InvalidStateError("Primitive is already set to '{primitive}'.".format(primitive=self.primitive))

        if not issubclass(primitive, base.PrimitiveBase):
            raise exceptions.InvalidArgumentTypeError("Primitive argument is not a subclass of 'PrimitiveBase' class.")

        self.primitive = primitive

        self._generate_and_update()

    def _validate_contact_information(self, metadata: typing.Dict[str, typing.Any]) -> None:
        # See https://gitlab.com/datadrivendiscovery/d3m/issues/178 for motivation for this check.

        # If it is a locally registered/used primitive, we do not validate contact information.
        if 'installation' not in metadata:
            return

        if 'source' not in metadata:
            logger.warning(
                "%(python_path)s: No \"source\" field in the primitive metadata. Metadata should contain contact information and bug reporting URI.",
                {
                    'python_path': metadata['python_path'],
                },
            )
            return

        if not metadata['source'].get('contact', None):
            logger.warning(
                "%(python_path)s: Contact information such as the email address of the author "
                "(e.g., \"mailto:author@example.com\") should be specified in primitive metadata in its \"source.contact\" field.",
                {
                    'python_path': metadata['python_path'],
                },
            )

        # If the list is empty, it is also false.
        if not metadata['source'].get('uris', None):
            logger.warning(
                "%(python_path)s: A bug reporting URI should be specified in primitive metadata in its \"source.uris\" field.",
                {
                    'python_path': metadata['python_path'],
                },
            )

    # Make sure a primitive provides a description (through docstring). Because we use special metaclass
    # which inherits description from a base class, we have to check the description itself.
    # See: https://gitlab.com/datadrivendiscovery/d3m/issues/167
    def _validate_description(self, metadata: typing.Dict[str, typing.Any]) -> None:
        # Importing here to prevent import cycle.
        from d3m.primitive_interfaces import base

        if 'description' not in metadata or not metadata['description'] or metadata['description'].startswith(base.DEFAULT_DESCRIPTION):
            logger.warning(
                "%(python_path)s: Primitive is not providing a description through its docstring.",
                {
                    'python_path': metadata['python_path'],
                },
            )

    # Checks that the primitive's Python path complies with namespace requirements.
    # See: https://gitlab.com/datadrivendiscovery/d3m/issues/3
    # TODO: Convert the warning messages to exceptions after January 2019.
    def _validate_namespace_compliance(self, python_path: str, primitive_family: typing.Union[PrimitiveFamily, str]) -> None:  # type: ignore
        segments = python_path.split('.')

        if len(segments) != 5:
            logger.warning(
                "%(python_path)s: Primitive's Python path does not adhere to d3m.primitives namespace specification "
                "(see https://gitlab.com/datadrivendiscovery/d3m/issues/3). Reason: must have 5 segments. "
                "This API will be made mandatory after January 2019 and this warning will become an exception.",
                {
                    'python_path': python_path,
                },
            )
        else:
            if segments[0] != 'd3m' or segments[1] != 'primitives':
                logger.warning(
                    "%(python_path)s: Primitive's Python path does not adhere to d3m.primitives namespace specification "
                    "(see https://gitlab.com/datadrivendiscovery/d3m/issues/3). Reason: must start with \"d3m.primitives\". "
                    "This API will be made mandatory after January 2019 and this warning will become an exception.",
                    {
                        'python_path': python_path,
                    },
                )

            family = segments[2]
            name = segments[3]
            kind = segments[4]

            # "primitive_family" could also already be a string.
            if isinstance(primitive_family, str):
                primitive_family_name = primitive_family
            else:
                primitive_family_name = primitive_family.name

            if family != primitive_family_name.lower():  # type: ignore
                logger.warning(
                    "%(python_path)s: Primitive's Python path does not adhere to d3m.primitives namespace specification "
                    "(see https://gitlab.com/datadrivendiscovery/d3m/issues/3). Reason: primitive family segment must match primitive's primitive family. "
                    "This API will be made mandatory after January 2019 and this warning will become an exception.",
                    {
                        'python_path': python_path,
                    },
                )

            if name not in primitive_names.PRIMITIVE_NAMES:
                logger.warning(
                    "%(python_path)s: Primitive's Python path does not adhere to d3m.primitives namespace specification "
                    "(see https://gitlab.com/datadrivendiscovery/d3m/issues/3). Reason: must have a known primitive name segment.",
                    {
                        'python_path': python_path,
                    },
                )

            if not kind[0].isupper():
                logger.warning(
                    "%(python_path)s: Primitive's Python path does not adhere to d3m.primitives namespace specification "
                    "(see https://gitlab.com/datadrivendiscovery/d3m/issues/3). Reason: primitive kind segment must start with upper case. "
                    "This API will be made mandatory after January 2019 and this warning will become an exception.",
                    {
                        'python_path': python_path,
                    },
                )

    def _validate(self) -> None:
        metadata = self.query()

        PRIMITIVE_SCHEMA_VALIDATOR.validate(metadata)

        self._validate_installation()
        self._validate_hyperparams_to_tune()
        self._validate_optional_constructor_arguments()
        self._validate_namespace_compliance(metadata['python_path'], metadata['primitive_family'])
        self._validate_contact_information(metadata)
        self._validate_description(metadata)

    def _generate_and_update(self) -> None:
        generated_metadata = self._generate_metadata_for_primitive()

        self._update_in_place((), generated_metadata, self._current_metadata)

        self._validate()

    def _validate_installation(self) -> None:
        for entry in self.query().get('installation', []):
            # We can check simply equality because metadata enumerations are equal to strings as well,
            # and "entry['type']" can be both a string or an enumeration instance.
            if entry['type'] != PrimitiveInstallationType.PIP:
                continue

            if 'package' in entry:
                if '/' in entry['package']:
                    raise exceptions.InvalidMetadataError("Invalid package name '{package_name}'. If you want to use an URI pointing to a package, use 'package_uri' instead.".format(
                        package_name=entry['package'],
                    ))

                continue

            if 'package_uri' not in entry:
                continue

            if entry['package_uri'].startswith('git+git@'):
                # "git+git@git.myproject.org:MyProject" format cannot be parsed with urlparse.
                raise exceptions.InvalidMetadataError("Only git+http and git+https URI schemes are allowed.")

            parsed_uri = url_parse.urlparse(entry['package_uri'])

            # It is not a git pip URI. For now we then do not validate it.
            if not parsed_uri.scheme.startswith('git'):
                continue

            if parsed_uri.scheme not in ['git+http', 'git+https']:
                raise exceptions.InvalidMetadataError("Only git+http and git+https URI schemes are allowed.")

            if '@' not in parsed_uri.path:
                raise exceptions.InvalidMetadataError("Package URI does not include a commit hash: {package_uri}".format(package_uri=entry['package_uri']))

            path, commit_hash = parsed_uri.path.rsplit('@', 1)

            if not COMMIT_HASH_REGEX.match(commit_hash):
                raise exceptions.InvalidMetadataError("Package URI does not include a commit hash: {package_uri}".format(package_uri=entry['package_uri']))

            if not parsed_uri.fragment:
                raise exceptions.InvalidMetadataError("Package URI does not include a '#egg=package_name' URI suffix.")

            parsed_fragment = url_parse.parse_qs(parsed_uri.fragment, strict_parsing=True)

            if 'egg' not in parsed_fragment:
                raise exceptions.InvalidMetadataError("Package URI does not include a '#egg=package_name' URI suffix.")

    def _validate_optional_constructor_arguments(self) -> None:
        installation = self.query().get('installation', [])

        containers = [entry for entry in installation if entry.get('type', None) == PrimitiveInstallationType.DOCKER]
        if containers and 'docker_containers' not in self.query()['primitive_code'].get('instance_methods', {})['__init__']['arguments']:
            raise exceptions.InvalidPrimitiveCodeError("Primitive defines a Docker container dependency but does not accept 'docker_containers' argument to the constructor.")

        volumes = [entry for entry in installation if entry.get('type', None) in [PrimitiveInstallationType.FILE, PrimitiveInstallationType.TGZ]]
        if volumes and 'volumes' not in self.query()['primitive_code'].get('instance_methods', {})['__init__']['arguments']:
            raise exceptions.InvalidPrimitiveCodeError("Primitive defines a volume dependency but does not accept 'volumes' argument to the constructor.")

    def _validate_hyperparams_to_tune(self) -> None:
        hyperparams = self.query()['primitive_code'].get('hyperparams', {})

        for name in self.query().get('hyperparams_to_tune', []):
            if name not in hyperparams:
                raise exceptions.InvalidMetadataError("Hyper-parameter in 'hyperparams_to_tune' metadata does not exist: {name}".format(name=name))

    def _generate_metadata_for_primitive(self) -> typing.Dict[str, typing.Any]:
        # Importing here to prevent import cycle.
        from d3m.primitive_interfaces import base

        type_arguments = self._get_type_arguments()
        class_attributes = self._get_class_attributes()
        hyperparams_class = typing.cast(typing.Type[hyperparams_module.Hyperparams], type_arguments[base.Hyperparams])
        arguments, instance_methods = self._get_arguments_and_methods(hyperparams_class, type_arguments)
        self._validate_constructor(instance_methods)
        self._validate_multi_produce(instance_methods)
        self._validate_fit_multi_produce(instance_methods)
        hyperparams = self._get_hyperparams(hyperparams_class)
        class_methods = self._get_class_methods(type_arguments)
        instance_attributes = self._get_instance_attributes()
        params = self._get_params(type_arguments)

        # Sanity check.
        hyperparams_keys = set(hyperparams.keys())
        # We can check simply equality because metadata enumerations are equal to strings as well,
        # and "argument['kind']" can be both a string or an enumeration instance.
        non_hyperparameter_arguments_keys = {name for name, argument in arguments.items() if argument['kind'] != PrimitiveArgumentKind.HYPERPARAMETER}
        overlapping_keys = hyperparams_keys & non_hyperparameter_arguments_keys
        if len(overlapping_keys):
            raise exceptions.InvalidPrimitiveCodeError("Hyper-paramater names are overlapping with non-hyperparameter argument names: {overlapping_keys}".format(overlapping_keys=overlapping_keys))

        primitive_code = {
            # We have to convert parameters to their names because JSON schema supports only strings for keys.
            'class_type_arguments': {parameter.__name__: argument for parameter, argument in type_arguments.items()},
            'interfaces_version': d3m.__version__,
            'interfaces': self._get_interfaces(),
            'hyperparams': hyperparams,
            'arguments': arguments,
            'class_methods': class_methods,
            'instance_methods': instance_methods,
            'class_attributes': class_attributes,
            'instance_attributes': instance_attributes,
        }

        if params is not None:
            primitive_code['params'] = params

        result = {
            'schema': PRIMITIVE_SCHEMA_VERSION,
            'original_python_path': '{module}.{class_name}'.format(
                module=self.primitive.__module__,
                class_name=self.primitive.__name__,
            ),
            'primitive_code': primitive_code,
            'structural_type': self.primitive,
        }

        description = inspect.cleandoc(getattr(self.primitive, '__doc__', None) or '') or None
        if description is not None:
            result['description'] = description

        digest = self._get_primitive_digest()
        if digest is not None:
            result['digest'] = digest

        return result

    def _get_primitive_digest(self) -> typing.Optional[str]:
        primitive_metadata = self.query()

        # We use installation metadata for digest because it uniquely identifies the content of the primitive.
        # TODO: Some primitives install extra code/data from their setup.py during installation. Could we capture that with digest as well?
        installation = primitive_metadata.get('installation', None)

        if not installation:
            return None

        to_digest = utils.to_json_structure({
            # We include primitive ID as well, so that different primitives
            # from the same package do not have the same digest.
            'id': primitive_metadata['id'],
            'installation': installation,
        })

        return utils.compute_digest(to_digest)

    # Using typing.TypeVar in type signature does not really work, so we are using type instead.
    # See: https://github.com/python/typing/issues/520
    def _get_type_arguments(self) -> typing.Dict[type, type]:
        # Importing here to prevent import cycle.
        from d3m.primitive_interfaces import base

        # This call also catches if type parameter has been overridden with a new type variable.
        # This means that we for free get to make sure type parameters from the base class stay
        # as they are expected to be. It also fetches them recursively, so one cannot hide a
        # type parameter (but can fix it to a fixed type instead of leaving it open for a
        # subclass to choose it).
        type_arguments = utils.get_type_arguments(self.primitive, unique_names=True)

        for parameter, argument in type_arguments.items():
            # Params type argument is optional and can be set to None.
            if parameter == base.Params and issubclass(argument, type(None)):
                continue

            if not utils.is_subclass(argument, parameter):
                raise exceptions.InvalidPrimitiveCodeError("Type parameter '{name}' has type '{type}' and not an expected type: {expected}".format(
                    name=parameter.__name__, type=argument, expected=parameter.__bound__,  # type: ignore
                ))

        return type_arguments

    def _resolve_type(self, obj: type, type_arguments: typing.Dict[type, type]) -> type:
        if obj in type_arguments:
            return type_arguments[obj]
        else:
            return obj

    def _get_interfaces(self) -> typing.Tuple[str, ...]:
        mro = [parent for parent in inspect.getmro(self.primitive) if parent.__module__.startswith('d3m.primitive_interfaces.')]

        interfaces: typing.List[str] = []
        for parent in mro:
            interface = utils.get_full_name(parent)
            # Remove package name.
            interface = '.'.join(interface.split('.')[2:])
            if interface not in interfaces:
                interfaces.append(interface)

        if not len(interfaces):
            raise exceptions.InvalidPrimitiveCodeError("The primitive does not implement a standard interface.")

        return tuple(interfaces)

    # Using typing.TypeVar in type signature does not really work, so we are using type instead.
    # See: https://github.com/python/typing/issues/520
    def _get_params(self, type_arguments: typing.Dict[type, type]) -> typing.Optional[typing.Dict[str, type]]:
        # Importing here to prevent import cycle.
        from d3m.primitive_interfaces import base

        params = type_arguments.get(base.Params, type(None))

        if issubclass(params, type(None)):
            return None

        return params.__params_items__  # type: ignore

    def _get_hyperparams(self, hyperparams_class: 'typing.Type[hyperparams_module.Hyperparams]') -> typing.Dict[str, typing.Dict]:
        # We check this here and not during hyper-parameter construction itself because
        # we want to require this only once it is used with a primitive. Hyper-parameters
        # might be used and constructed in other settings as well.
        for hyperparameter_name, hyperparameter in hyperparams_class.configuration.items():
            if not set(hyperparameter.semantic_types) & HYPERPARAMETER_REQUIRED_SEMANTIC_TYPES:
                raise exceptions.InvalidPrimitiveCodeError(
                    "Hyper-parameter '{hyperparameter_name}' does not contain any of required semantic types: {required}".format(
                        hyperparameter_name=hyperparameter_name,
                        required=sorted(HYPERPARAMETER_REQUIRED_SEMANTIC_TYPES),
                    ),
                )

        return hyperparams_class.to_simple_structure()

    def _get_class_attributes(self) -> typing.Dict[str, type]:
        result = {}

        for attribute_name, attribute in inspect.getmembers(self.primitive):
            if attribute_name.startswith('_'):
                continue

            if utils.is_class_method_on_class(attribute) or utils.is_instance_method_on_class(attribute):
                continue

            result[attribute_name] = type(attribute)

        result_keys = set(result.keys())
        expected_result_keys = set(EXPECTED_CLASS_ATTRIBUTES.keys())

        missing = expected_result_keys - result_keys
        if len(missing):
            raise exceptions.InvalidPrimitiveCodeError("Not all expected public class attributes exist: {missing}".format(missing=missing))

        extra = result_keys - expected_result_keys
        if len(extra):
            raise exceptions.InvalidPrimitiveCodeError("Additional unexpected public class attributes exist, consider making them private by prefixing them with '_': {extra}".format(extra=extra))

        for attribute_name, attribute in result.items():
            if not utils.is_subclass(attribute, EXPECTED_CLASS_ATTRIBUTES[attribute_name]):
                raise exceptions.InvalidPrimitiveCodeError("Class attribute '{attribute_name}' does not have an expected type.".format(attribute_name=attribute_name))

        return result

    # Using typing.TypeVar in type signature does not really work, so we are using type instead.
    # See: https://github.com/python/typing/issues/520
    def _get_arguments_and_methods(self, hyperparams_class: 'typing.Type[hyperparams_module.Hyperparams]', type_arguments: typing.Dict[type, type]) \
            -> typing.Tuple[typing.Dict[str, typing.Dict], typing.Dict[str, typing.Dict]]:
        # Importing here to prevent import cycle.
        from d3m.primitive_interfaces import base
        from d3m import types as types_module

        arguments: typing.Dict[str, typing.Dict] = {}
        methods: typing.Dict[str, typing.Dict] = {}

        for method_name, method in inspect.getmembers(self.primitive):
            if method_name.startswith('_') and method_name != '__init__':
                continue

            if not utils.is_instance_method_on_class(method):
                continue

            # To make get_type_hints find method's module while the primitive's
            # module is still being defined (and this method was indirectly called
            # from primitive's metaclass).
            method.im_class = self.primitive

            type_hints = type_util.get_type_hints(method)

            if not type_hints:
                raise exceptions.InvalidPrimitiveCodeError("Cannot get types for method '{method_name}'.".format(method_name=method_name))

            if 'return' not in type_hints:
                raise exceptions.InvalidPrimitiveCodeError("Method '{method_name}' is missing a type for the return value.".format(method_name=method_name))

            if method_name.startswith('produce_') or method_name == 'produce':
                method_kind = PrimitiveMethodKind.PRODUCE

                if getattr(method, '__singleton__', False):
                    singleton_produce_method = True
                else:
                    singleton_produce_method = False

                method_inputs_across_samples = getattr(method, '__inputs_across_samples__', ())
            elif method_name.startswith('produce'):
                raise exceptions.InvalidPrimitiveCodeError("Produce method should start with 'produce_' and not be '{method_name}'.".format(method_name=method_name))
            else:
                method_kind = PrimitiveMethodKind.OTHER

                singleton_produce_method = None
                method_inputs_across_samples = None

                if hasattr(method, '__singleton__'):
                    raise exceptions.InvalidPrimitiveCodeError("Only produce methods can be set as singleton or not: {method_name}.".format(method_name=method_name))
                if hasattr(method, '__inputs_across_samples__'):
                    raise exceptions.InvalidPrimitiveCodeError("Only arguments of produce methods can be set to compute accross samples or not: {method_name}.".format(method_name=method_name))

            method_arguments = []

            # We skip the first argument (self).
            for argument_name, argument in list(inspect.signature(method).parameters.items())[1:]:
                if argument.kind != inspect.Parameter.KEYWORD_ONLY:
                    raise exceptions.InvalidPrimitiveCodeError("Method '{method_name}' has a non-keyword argument '{argument_name}'.".format(method_name=method_name, argument_name=argument_name))

                has_default = argument.default is not inspect.Parameter.empty

                if argument_name.startswith('_'):
                    if not has_default:
                        raise exceptions.InvalidPrimitiveCodeError("Method '{method_name}' has a non-optional private argument '{argument_name}'.".format(
                            method_name=method_name, argument_name=argument_name,
                        ))

                    continue

                if not ARGUMENT_NAME_REGEX.match(argument_name):
                    raise exceptions.InvalidPrimitiveCodeError("Method '{method_name}' has an argument with an invalid name '{argument_name}'.".format(
                        method_name=method_name, argument_name=argument_name
                    ))

                if argument_name not in type_hints:
                    raise exceptions.InvalidPrimitiveCodeError("Method '{method_name}' is missing a type for argument '{argument_name}'.".format(method_name=method_name, argument_name=argument_name))

                argument_type = self._resolve_type(type_hints[argument_name], type_arguments)

                standard_argument_description = typing.cast(
                    typing.Dict,
                    STANDARD_RUNTIME_ARGUMENTS.get(argument_name, None) or STANDARD_PIPELINE_ARGUMENTS.get(argument_name, None),
                )
                if standard_argument_description is not None:
                    try:
                        expected_type = self._get_argument_type(standard_argument_description, type_arguments)
                    except KeyError:
                        raise exceptions.InvalidPrimitiveCodeError(
                            "Method '{method_name}' has an argument '{argument_name}' for which an expected type cannot be determined. Is a type parameter missing?".format(
                                method_name=method_name, argument_name=argument_name,
                            )
                        )

                    # Types have to match here exactly. This is what class type arguments are for.
                    if argument_type != expected_type:
                        raise exceptions.InvalidPrimitiveCodeError(
                            "Method '{method_name}' has an argument '{argument_name}' with type '{argument_type}' and not an expected type: {expected_type}".format(
                                method_name=method_name, argument_name=argument_name,
                                argument_type=argument_type, expected_type=expected_type,
                            )
                        )

                    if 'default' in standard_argument_description:
                        if not has_default:
                            raise exceptions.InvalidPrimitiveCodeError(
                                "Method '{method_name}' has an argument '{argument_name}' which does not have a default value, but it should.".format(
                                    method_name=method_name, argument_name=argument_name,
                                )
                            )

                        if argument.default != standard_argument_description['default']:
                            raise exceptions.InvalidPrimitiveCodeError(
                                "Method '{method_name}' has an argument '{argument_name}' with a different default value: {argument_default} != {expected_default}.".format(
                                    method_name=method_name, argument_name=argument_name,
                                    argument_default=argument.default, expected_default=standard_argument_description['default'],
                                )
                            )

                    else:
                        if has_default:
                            raise exceptions.InvalidPrimitiveCodeError("Method '{method_name}' has an argument '{argument_name}' which has a default value, but it should not.".format(
                                method_name=method_name, argument_name=argument_name,
                            ))

                    if argument_name in STANDARD_RUNTIME_ARGUMENTS:
                        argument_kind = PrimitiveArgumentKind.RUNTIME
                    else:
                        assert argument_name in STANDARD_PIPELINE_ARGUMENTS, "argument_name not in STANDARD_PIPELINE_ARGUMENTS"
                        argument_kind = PrimitiveArgumentKind.PIPELINE

                # Constructor cannot have additional non-private custom arguments.
                elif method_name == '__init__':
                    raise exceptions.InvalidPrimitiveCodeError(
                        "Constructor cannot have non-private custom arguments, but it has an argument '{argument_name}'.".format(
                            argument_name=argument_name,
                        )
                    )

                elif argument_name in hyperparams_class.configuration:
                    # Types have to match here exactly.
                    if argument_type != hyperparams_class.configuration[argument_name].structural_type:
                        raise exceptions.InvalidPrimitiveCodeError("Method '{method_name}' has an argument '{argument_name}' overriding a hyper-parameter with a different type: {argument_type} != {hyperparameter_type}.".format(  # noqa
                            method_name=method_name, argument_name=argument_name,
                            argument_type=argument_type, hyperparameter_type=hyperparams_class.configuration[argument_name].structural_type,
                        ))

                    # Arguments overriding a hyper-parameter should not have a default value and caller should pass a value in.
                    if has_default:
                        raise exceptions.InvalidPrimitiveCodeError(
                            "Method '{method_name}' has an argument '{argument_name}' overriding a hyper-parameter which has a default value, but it should not.".format(
                                method_name=method_name, argument_name=argument_name,
                            )
                        )

                    argument_kind = PrimitiveArgumentKind.HYPERPARAMETER

                else:
                    # Any other argument should be something the rest of the pipeline can provide:
                    # a container value, data value, or another primitive.
                    expected_types: typing.Tuple[type, ...] = types_module.Container + types_module.Data + (base.PrimitiveBase,)

                    if not utils.is_subclass(argument_type, typing.Union[expected_types]):
                        raise exceptions.InvalidPrimitiveCodeError(
                            "Method '{method_name}' has an argument '{argument_name}' with type '{argument_type}' and not an expected type: {expected_types}".format(
                                method_name=method_name, argument_name=argument_name,
                                argument_type=argument_type, expected_types=expected_types
                            )
                        )

                    # It should not have a default. Otherwise it is easy to satisfy the argument
                    # (just never connect anything to it in the pipeline).
                    if has_default:
                        raise exceptions.InvalidPrimitiveCodeError("Method '{method_name}' has an argument '{argument_name}' which has a default value, but it should not.".format(
                            method_name=method_name, argument_name=argument_name,
                        ))

                    argument_kind = PrimitiveArgumentKind.PIPELINE

                method_arguments.append(argument_name)

                if argument_name in arguments:
                    if argument_type != arguments[argument_name]['type']:
                        raise exceptions.InvalidPrimitiveCodeError("Method '{method_name}' has an argument '{argument_name}' which does not match a type of a previous argument with the same name: {argument_type} != {previous_type}".format(  # noqa
                            method_name=method_name, argument_name=argument_name,
                            argument_type=argument_type, previous_type=arguments[argument_name]['type'],
                        ))

                    # This should hold because it depends only on the argument name.
                    assert argument_kind == arguments[argument_name]['kind'], "argument_kind mismatch"

                    if has_default:
                        if 'default' not in arguments[argument_name]:
                            raise exceptions.InvalidPrimitiveCodeError("Method '{method_name}' has an argument '{argument_name}' which has a default value, but a previous argument with the same name did not have a default value.".format(  # noqa
                                method_name=method_name, argument_name=argument_name,
                            ))
                        elif argument.default != arguments[argument_name]['default']:
                            raise exceptions.InvalidPrimitiveCodeError("Method '{method_name}' has an argument '{argument_name}' which does not have the same default value as a previous argument with the same name: {argument_default} != {previous_default}".format(  # noqa
                                method_name=method_name, argument_name=argument_name,
                                argument_default=argument.default,
                                previous_default=arguments[argument_name]['default'],
                            ))
                    else:
                        if 'default' in arguments[argument_name]:
                            raise exceptions.InvalidPrimitiveCodeError("Method '{method_name}' has an argument '{argument_name}' which does not have a default value, but a previous argument with the same name had a default value.".format(  # noqa
                                method_name=method_name, argument_name=argument_name,
                            ))

                else:
                    arguments[argument_name] = {
                        'type': argument_type,
                        'kind': argument_kind,
                    }

                    if has_default:
                        arguments[argument_name]['default'] = argument.default

            methods[method_name] = {
                'kind': method_kind,
                'arguments': method_arguments,
                'returns': self._resolve_type(type_hints['return'], type_arguments),
            }

            if singleton_produce_method is not None:
                methods[method_name]['singleton'] = singleton_produce_method

            if method_inputs_across_samples is not None:
                for method_input in method_inputs_across_samples:
                    if method_input not in method_arguments:
                        raise exceptions.InvalidPrimitiveCodeError("Method '{method_name}' has an argument '{method_input}' set as computing across samples, but it does not exist.".format(
                            method_name=method_name, method_input=method_input,
                        ))

                    if arguments[method_input]['kind'] != PrimitiveArgumentKind.PIPELINE:
                        raise exceptions.InvalidPrimitiveCodeError("Method '{method_name}' has an argument '{method_input}' set as computing across samples, but it is not a PIPELINE argument.".format(
                            method_name=method_name, method_input=method_input,
                        ))

                methods[method_name]['inputs_across_samples'] = method_inputs_across_samples

            description = inspect.cleandoc(getattr(method, '__doc__', None) or '') or None
            if description is not None:
                methods[method_name]['description'] = description

        return arguments, methods

    # Using typing.TypeVar in type signature does not really work, so we are using type instead.
    # See: https://github.com/python/typing/issues/520
    def _get_argument_type(self, argument_description: typing.Dict[str, typing.Any], type_arguments: typing.Dict[type, type]) -> type:
        if 'get_type' in argument_description:
            return argument_description['get_type'](type_arguments)
        else:
            return argument_description['type']

    # Using typing.TypeVar in type signature does not really work, so we are using type instead.
    # See: https://github.com/python/typing/issues/520
    def _get_class_methods(self, type_arguments: typing.Dict[type, type]) -> typing.Dict[str, typing.Dict]:
        methods: typing.Dict[str, typing.Dict] = {}

        for method_name, method in inspect.getmembers(self.primitive):
            if method_name.startswith('_'):
                continue

            if not utils.is_class_method_on_class(method):
                continue

            type_hints = type_util.get_type_hints(method)

            if not type_hints:
                raise exceptions.InvalidPrimitiveCodeError("Cannot get types for method '{method_name}'.".format(method_name=method_name))

            if 'return' not in type_hints:
                raise exceptions.InvalidPrimitiveCodeError("Method '{method_name}' is missing a type for the return value.".format(method_name=method_name))

            method_arguments = {}

            for argument_name, argument in inspect.signature(method).parameters.items():
                if argument.kind != inspect.Parameter.KEYWORD_ONLY:
                    raise exceptions.InvalidPrimitiveCodeError("Method '{method_name}' has a non-keyword argument '{argument_name}'.".format(method_name=method_name, argument_name=argument_name))

                has_default = argument.default is not inspect.Parameter.empty

                if argument_name.startswith('_'):
                    if not has_default:
                        raise exceptions.InvalidPrimitiveCodeError("Method '{method_name}' has a non-optional private argument '{argument_name}'.".format(
                            method_name=method_name, argument_name=argument_name,
                        ))

                    continue

                if argument_name not in type_hints:
                    raise exceptions.InvalidPrimitiveCodeError("Method '{method_name}' is missing a type for argument '{argument_name}'.".format(method_name=method_name, argument_name=argument_name))

                argument_type = self._resolve_type(type_hints[argument_name], type_arguments)

                argument_description = {
                    'type': argument_type,
                }

                if has_default:
                    argument_description['default'] = argument.default

                method_arguments[argument_name] = argument_description

            methods[method_name] = {
                'arguments': method_arguments,
                'returns': self._resolve_type(type_hints['return'], type_arguments),
            }

            description = inspect.cleandoc(getattr(method, '__doc__', None) or '') or None
            if description is not None:
                methods[method_name]['description'] = description

        return methods

    def _get_docker_containers(self) -> typing.Tuple[str, ...]:
        installation = self.query().get('installation', [])

        containers: typing.List[str] = []

        for entry in installation:
            # We can check simply equality because metadata enumerations are equal to strings as well,
            # and "entry['type']" can be both a string or an enumeration instance.
            if entry.get('type', None) != PrimitiveInstallationType.DOCKER:
                continue

            key = entry.get('key', None)
            if key:
                containers.append(key)

        containers_set = set(containers)
        if len(containers_set) != len(containers):
            for key in containers_set:
                containers.remove(key)
            raise exceptions.InvalidMetadataError("Same Docker image key reused across multiple installation entries: {extra_keys}".format(extra_keys=containers))

        return tuple(containers)

    def _get_volumes(self) -> typing.Tuple[str, ...]:
        installation = self.query().get('installation', [])

        volumes: typing.List[str] = []

        for entry in installation:
            # We can check simply equality because metadata enumerations are equal to strings as well,
            # and "entry['type']" can be both a string or an enumeration instance.
            if entry.get('type', None) not in [PrimitiveInstallationType.FILE, PrimitiveInstallationType.TGZ]:
                continue

            key = entry.get('key', None)
            if key:
                volumes.append(key)

        volumes_set = set(volumes)
        if len(volumes_set) != len(volumes):
            for key in volumes_set:
                volumes.remove(key)
            raise exceptions.InvalidMetadataError("Same volume key reused across multiple installation entries: {extra_keys}".format(extra_keys=volumes))

        return tuple(volumes)

    def _validate_constructor(self, instance_methods: typing.Dict[str, typing.Dict]) -> None:
        if '__init__' not in instance_methods:
            raise exceptions.InvalidPrimitiveCodeError("Constructor is missing.")

        if 'hyperparams' not in instance_methods['__init__']['arguments']:
            raise exceptions.InvalidPrimitiveCodeError("Constructor's argument 'hyperparams' is required.")

    def _validate_multi_produce(self, instance_methods: typing.Dict[str, typing.Dict]) -> None:
        if 'produce' not in instance_methods:
            raise exceptions.InvalidPrimitiveCodeError("'produce' method is missing.")

        if 'multi_produce' not in instance_methods:
            raise exceptions.InvalidPrimitiveCodeError("'multi_produce' method is missing.")

        # Initialize with runtime arguments.
        expected_arguments = {'produce_methods', 'timeout', 'iterations'}
        for method_name, method in instance_methods.items():
            if method['kind'] != PrimitiveMethodKind.PRODUCE:
                continue

            if 'produce_methods' in method['arguments']:
                raise exceptions.InvalidPrimitiveCodeError("Produce method cannot use 'produce_methods' argument: {method_name}".format(method_name=method_name))

            expected_arguments.update(method['arguments'])

        arguments = set(instance_methods['multi_produce']['arguments'])

        missing = expected_arguments - arguments
        if len(missing):
            raise exceptions.InvalidPrimitiveCodeError(
                "'multi_produce' method arguments have to be an union of all arguments of all produce methods, but it does not accept all expected arguments: {missing}".format(
                    missing=missing,
                )
            )

        extra = arguments - expected_arguments
        if len(extra):
            raise exceptions.InvalidPrimitiveCodeError(
                "'multi_produce' method arguments have to be an union of all arguments of all produce methods, but it accepts unexpected arguments: {extra}".format(
                    extra=extra,
                )
            )

    def _validate_fit_multi_produce(self, instance_methods: typing.Dict[str, typing.Dict]) -> None:
        if 'set_training_data' not in instance_methods:
            raise exceptions.InvalidPrimitiveCodeError("'set_training_data' method is missing.")

        if 'produce' not in instance_methods:
            raise exceptions.InvalidPrimitiveCodeError("'produce' method is missing.")

        if 'fit_multi_produce' not in instance_methods:
            raise exceptions.InvalidPrimitiveCodeError("'fit_multi_produce' method is missing.")

        # Initialize with runtime arguments.
        expected_arguments = {'produce_methods', 'timeout', 'iterations'}
        for method_name, method in instance_methods.items():
            if method['kind'] == PrimitiveMethodKind.PRODUCE:
                if 'produce_methods' in method['arguments']:
                    raise exceptions.InvalidPrimitiveCodeError("Produce method cannot use 'produce_methods' argument: {method_name}".format(method_name=method_name))

                expected_arguments.update(method['arguments'])

            elif method_name == 'set_training_data':
                if 'produce_methods' in method['arguments']:
                    raise exceptions.InvalidPrimitiveCodeError("'set_training_data' method cannot use 'produce_methods' argument: {method_name}".format(method_name=method_name))

                expected_arguments.update(method['arguments'])

        arguments = set(instance_methods['fit_multi_produce']['arguments'])

        missing = expected_arguments - arguments
        if len(missing):
            raise exceptions.InvalidPrimitiveCodeError(
                "'fit_multi_produce' method arguments have to be an union of all arguments of 'set_training_data' method and all produce methods, "
                "but it does not accept all expected arguments: {missing}".format(
                    missing=missing,
                )
            )

        extra = arguments - expected_arguments
        if len(extra):
            raise exceptions.InvalidPrimitiveCodeError(
                "'fit_multi_produce' method arguments have to be an union of all arguments of 'set_training_data' method and all produce methods, but it accepts unexpected arguments: {extra}".format(
                    extra=extra,
                )
            )

    # In the past we have validated instance attributes by creating an instance of the primitive and observe
    # which instance attributes were created in a constructor. This was potentially resource intensive because
    # primitives use constructor to initialize resources they use. Moreover, it did not detect attributes
    # added outside the constructor (even if such practice is bad, it does happen). We could maybe do some
    # static analysis instead, but it could also miss attributes, or have false positives. So, instead, we
    # just document standard instance attributes and this is it.
    # See: https://gitlab.com/datadrivendiscovery/d3m/issues/158
    def _get_instance_attributes(self) -> typing.Dict[str, type]:
        # Importing here to prevent import cycle.
        from d3m.primitive_interfaces import base

        # Primitive instance attributes are standardized and fixed.
        return {
            'hyperparams': hyperparams_module.Hyperparams,
            'random_seed': int,
            'docker_containers': typing.Dict[str, base.DockerContainer],
            'volumes': typing.Dict[str, str],
        }

    # Not adhering to Liskov substitution principle: we are not returning a list.
    def to_json_structure(self) -> typing.Dict:  # type: ignore
        """
        Converts primitive's metadata to a JSON-compatible structure.

        Returns
        -------
        Dict
            A JSON-compatible dict.
        """

        return utils.to_json_structure(self.to_simple_structure())

    # Not adhering to Liskov substitution principle: we are not returning a list.
    def to_simple_structure(self) -> typing.Dict:  # type: ignore
        """
        Converts primitive's metadata to a simple structure, similar to JSON, but with values
        left as Python values.

        Returns
        -------
        Dict
            A dict.
        """

        return super().to_simple_structure()[0]['metadata']


EXPECTED_CLASS_ATTRIBUTES = {
    'metadata': PrimitiveMetadata,
    'logger': logging.Logger,
}


def _get_inputs(type_arguments: typing.Dict[type, type]) -> type:
    # Importing here to prevent import cycle.
    from d3m.primitive_interfaces import base

    return type_arguments[base.Inputs]


def _get_outputs(type_arguments: typing.Dict[type, type]) -> type:
    # Importing here to prevent import cycle.
    from d3m.primitive_interfaces import base

    return type_arguments[base.Outputs]


def _get_input_labels(type_arguments: typing.Dict[type, type]) -> type:
    # Importing here to prevent import cycle.
    from d3m.primitive_interfaces import distance

    return type_arguments[distance.InputLabels]


# Arguments which can be fulfilled by other primitives in a pipeline.
STANDARD_PIPELINE_ARGUMENTS = {
    'inputs': {
        'get_type': _get_inputs,
    },
    'outputs': {
        'get_type': _get_outputs,
    },
    'input_labels': {
        'get_type': _get_input_labels,
    },
}


def _get_hyperparams(type_arguments: typing.Dict[type, type]) -> type:
    # Importing here to prevent import cycle.
    from d3m.primitive_interfaces import base

    return type_arguments[base.Hyperparams]


def _get_docker_containers(type_arguments: typing.Dict[type, type]) -> type:
    # Importing here to prevent import cycle.
    from d3m.primitive_interfaces import base

    return typing.Optional[typing.Dict[str, base.DockerContainer]]


def _get_params(type_arguments: typing.Dict[type, type]) -> type:
    # Importing here to prevent import cycle.
    from d3m.primitive_interfaces import base

    return type_arguments[base.Params]


def _get_gradient_outputs(type_arguments: typing.Dict[type, type]) -> type:
    # Importing here to prevent import cycle.
    from d3m.primitive_interfaces import base

    return base.Gradients[type_arguments[base.Outputs]]  # type: ignore


# Arguments which are meaningful only for a runtime executing a pipeline.
STANDARD_RUNTIME_ARGUMENTS = {
    'hyperparams': {
        'get_type': _get_hyperparams,
    },
    'random_seed': {
        'type': int,
        'default': 0,
    },
    'docker_containers': {
        'get_type': _get_docker_containers,
        'default': None,
    },
    'volumes': {
        'type': typing.Optional[typing.Dict[str, str]],
        'default': None,
    },
    'timeout': {
        'type': typing.Optional[float],
        'default': None,
    },
    'iterations': {
        'type': typing.Optional[int],
        'default': None,
    },
    'produce_methods': {
        'type': typing.Sequence[str],
    },
    'params': {
        'get_type': _get_params,
    },
    'num_samples': {
        'type': int,
        'default': 1,
    },
    'gradient_outputs': {
        'get_type': _get_gradient_outputs,
    },
    'fine_tune':  {
        'type': bool,
        'default': False,
    },
    'fine_tune_learning_rate': {
        'type': float,
        'default': 0.00001,
    },
    'fine_tune_weight_decay': {
        'type': float,
        'default': 0.00001,
    },
    'temperature': {
        'type': float,
        'default': 0,
    },
}


def metadata_serializer(obj: Metadata) -> dict:
    # If metadata has a link to the value, we do not want to serialize it here.
    if hasattr(obj, 'for_value'):
        for_value = obj.for_value  # type: ignore
        try:
            obj.for_value = None  # type: ignore
            data = {
                'metadata': pickle.dumps(obj),
            }
        finally:
            obj.for_value = for_value  # type: ignore
    else:
        data = {
            'metadata': pickle.dumps(obj),
        }

    return data


def metadata_deserializer(data: dict) -> Metadata:
    metadata = pickle.loads(data['metadata'])

    return metadata


if pyarrow_lib is not None:
    pyarrow_lib._default_serialization_context.register_type(
        Metadata, 'd3m.metadata',
        custom_serializer=metadata_serializer,
        custom_deserializer=metadata_deserializer,
    )
