Source code for id_translation._translator

import logging
import pickle
from collections.abc import Iterable, Mapping, Sequence
from copy import deepcopy
from datetime import timedelta
from time import perf_counter
from typing import (
    TYPE_CHECKING,
    Any,
    Generic,
    Literal,
    NoReturn,
    Self,
    Unpack,
    overload,
)

from rics.collections.dicts import InheritedKeysDict, MakeType
from rics.collections.misc import as_list
from rics.env.read import read_bool
from rics.misc import tname
from rics.paths import AnyPath, any_path_to_path
from rics.strings import format_seconds as fmt_sec
from rics.types import LiteralHelper

from . import logging as _logging
from ._tasks import MappingTask, NamesTask, TranslationTask
from ._utils.emit_warning import emit_warning
from .exceptions import (
    ConfigurationChangedError,
    ConnectionStatusError,
    MissingNamesError,
    TransformerConflictError,
    TranslationDisabledWarning,
)
from .fetching import Fetcher, MultiFetcher
from .fetching.types import IdsToFetch
from .mapping import Mapper
from .mapping.matrix import ScoreMatrix
from .mapping.types import UserOverrideFunction
from .offline import Format, TranslationMap
from .offline.types import (
    FormatType,
    PlaceholderTranslations,
    SourcePlaceholderTranslations,
)
from .testing import TestFetcher, TestMapper
from .toml import TranslatorFactory, meta
from .transform import TransformerStack, as_transformer
from .transform._impl.stack import has_member
from .transform.types import OnExistingTransformer, Transformer, Transformers, TransformersArg
from .translator_typing import CopyParams, FetcherCopyMode, FetcherTypes
from .types import (
    ID,
    CopyTranslatable,
    DictToId,
    DictToList,
    DictToOneTuple,
    DictToSet,
    DictToThreeTuple,
    DictToTwoTuple,
    DictToVarTuple,
    HasSources,
    IdType,
    IdTypes,
    InplaceTranslatable,
    Names,
    NameToSource,
    NameType,
    NameTypes,
    SourceType,
    Translatable,
)

LOGGER = logging.getLogger(__package__).getChild("Translator")


ON_EXISTING_HELPER: LiteralHelper[OnExistingTransformer] = LiteralHelper(
    OnExistingTransformer,
    default_name="on_existing",
    type_name="OnExistingTransformer",
)
FETCHER_COPY_MODE_HELPER: LiteralHelper[FetcherCopyMode] = LiteralHelper(
    FetcherCopyMode,
    default_name="fetcher",
    type_name="FetcherCopyMode",
)


ID_TRANSLATION_DISABLED = "ID_TRANSLATION_DISABLED"

_ON_EXISTING_CONFLICT_HINT = "Hint: Pass on_existing='append' to chain onto it, or 'overwrite' to replace it."

if TYPE_CHECKING:
    from uuid import UUID

    import pandas

    ID_TRANSLATION_PANDAS_IS_TYPED: bool = False


[docs] class Translator(Generic[NameType, SourceType, IdType], HasSources[SourceType]): r"""End-user interface for all translation tasks. See :meth:`Translator.translate <id_translation.Translator.translate>` for runtime configuration options. Any argument chosen when the ``Translator`` is created can be overridden with :meth:`Translator.copy <id_translation.Translator.copy>`. Use :meth:`~id_translation.Translator.go_offline` to store translations in memory. Args: fetcher: A :class:`~id_translation.fetching.Fetcher` or ready-to-use translations. fmt: String :class:`~id_translation.offline.Format` specification for translations. mapper: A :class:`~id_translation.mapping.Mapper` instance for binding names to sources. default_fmt: Alternative :class:`~id_translation.offline.Format` to use for fallback translation of unknown IDs. default_fmt_placeholders: Shared and/or source-specific default placeholder values for unknown IDs. See :meth:`InheritedKeysDict.make() <rics.collections.dicts.InheritedKeysDict.make>` for details. enable_uuid_heuristics: Improves matching when :py:class:`~uuid.UUID`-like IDs are in use. transformers: A dict ``{source: transformer}`` of initialized :class:`~id_translation.transform.types.Transformer` instances. Values may be a sequence to chain per source. Equivalent to calling :meth:`~id_translation.Translator.register_transformer` for each key, which is the way to register once the :attr:`~id_translation.types.HasSources.sources` are known. .. _translator-docstring-example: Examples: Basic usage. For a more complete use case, see the :ref:`dvdrental` example. Assume that we have data for people and animals as in the tables below:: people: animals: id | name id | name | is_nice ------+--------- ----+--------+--------- 1991 | Richard 0 | Tarzan | false 1999 | Sofia 1 | Morris | true 1904 | Fred 2 | Simba | true In most real cases we'd fetch this table from somewhere. In this case, however, there's so little data that we can simply enumerate the components needed for translation ourselves. >>> from id_translation import Translator >>> translation_data = { ... "animals": { ... "id": [0, 1, 2], ... "name": ["Tarzan", "Morris", "Simba"], ... "is_nice": [False, True, True], ... }, ... "people": {1999: "Sofia", 1991: "Richard", 1904: "Fred"}, ... } >>> fmt = "{id}:{name}[, nice={is_nice}]" >>> translator = Translator(translation_data, fmt=fmt) Since `people` only has columns `id` and `name`, we can use the simplified ``{id: name}`` data format. We're using the full format for `animals` since we have an additional `is_nice` column in this table. We didn't define a :class:`~id_translation.mapping.Mapper`, so the column names must match exactly. >>> import pandas as pd >>> df = pd.DataFrame({"animals": [0, 2], "people": [1991, 1999]}) >>> translator.translate(df) # Returns a copy animals people 0 0:Tarzan, nice=False 1991:Richard 1 2:Simba, nice=True 1999:Sofia Check out the :ref:`translation-primer` to learn how this is done "under the hood". """ def __init__( self, fetcher: FetcherTypes[NameType, SourceType, IdType] | None = None, *, fmt: FormatType = Format.DEFAULT, mapper: Mapper[NameType, SourceType, None] | None = None, default_fmt: FormatType = Format.DEFAULT_FAILED, default_fmt_placeholders: MakeType[SourceType, str, Any] | None = None, enable_uuid_heuristics: bool = False, transformers: TransformersArg[SourceType, IdType] | None = None, ) -> None: # The data source, assigned below. Declared first because `_register_transformer` reads both to decide # whether the sources are known yet, and the loop below runs before either has an answer. self._fetcher: Fetcher[SourceType, IdType] | None = None self._cached_tmap: TranslationMap[NameType, SourceType, IdType] | None = None self._transformers: Transformers[SourceType, IdType] = {} # Registrations not yet checked against the known sources; see `_warn_on_transformers_for_unknown_sources`. # The transitions that turn this instance into a snapshot discard what is queued instead of checking it, # which is what exempts those registrations for good. self._unverified_sources: list[SourceType] = [] # Whether the `Fetcher.get_transformer` pass has run; see `_apply_fetcher_transformers`. Nothing is # cached from it, so a pass that raised simply runs again. self._transformers_queried: bool = False for source, transformer in (transformers or {}).items(): # Not the public method: an override may rely on attributes this constructor has not created yet. self._register_transformer(source, transformer, on_existing="raise") self._fmt = Format.parse(fmt) self._default_fmt_placeholders, self._default_fmt = _handle_default(default_fmt, default_fmt_placeholders) self._enable_uuid_heuristics = enable_uuid_heuristics if fetcher is None: self._fetcher = TestFetcher([]) # No explicit sources if mapper: # pragma: no cover emit_warning( f"Mapper instance {mapper} given; consider creating a TestFetcher([sources..])-instance manually." ) else: mapper = TestMapper() emit_warning("No fetcher given. Translation data will be automatically generated.") elif isinstance(fetcher, Fetcher): self._fetcher = fetcher elif isinstance(fetcher, dict): source_translations = {source: PlaceholderTranslations.make(source, pht) for source, pht in fetcher.items()} self._cached_tmap = self._to_translation_map(source_translations) elif isinstance(fetcher, TranslationMap): # Extract raw translations and "overwrite" inherited properties, such as format specs and transformers. source_translations = fetcher._extract_translations() self._cached_tmap = self._to_translation_map(source_translations) else: raise TypeError(type(fetcher)) # pragma: no cover self._mapper: Mapper[NameType, SourceType, None] = mapper or Mapper() self._mapper.logger = LOGGER.getChild("map") self._config_metadata: meta.ConfigMetadata | None = None self._translated_names: tuple[int, NameToSource[NameType, SourceType]] | None = None if isinstance(fetcher, dict): # `initialize_sources()` never runs offline, so this is the only check inline data gets; see # `register_transformer` for why a snapshot is exempt. self._warn_on_transformers_for_unknown_sources() elif isinstance(fetcher, TranslationMap): # A snapshot may cover a subset of the original sources on purpose, so what the constructor registered is # dropped unchecked rather than reported. Registrations made from here on are queued and checked as usual. self._unverified_sources.clear() def __getstate__(self) -> Any: # `go_offline()` guarantees a serializable instance. Delegates, since a subclass may declare `__slots__`; # `object.__getstate__` reports that state as the second element of a tuple, which the plain-`__dict__` # case does not use. state = super().__getstate__() instance_dict, slots = state if isinstance(state, tuple) else (state, None) instance_dict = dict(instance_dict or {}) return (instance_dict, slots) if slots else instance_dict def __setstate__(self, state: Any) -> None: slots: dict[str, Any] = {} if isinstance(state, tuple): # A subclass with `__slots__`; see `__getstate__`. state, slots = state[0] or {}, state[1] or {} # Snapshots written by older versions predate fetcher-provided transformers; see `restore`. `False` is # right for those: nothing was ever asked, and restored instances are offline, so the state is inert until the # copy is given a fetcher. The empty queue is not inert: it is read while offline, and an old snapshot's # registrations were exempt in all but name, as `go_offline` says. state.setdefault("_transformers_queried", False) state.setdefault("_unverified_sources", []) # Offline snapshots used to record their state by omitting the fetcher rather than by nulling it. state.setdefault("_fetcher", None) self.__dict__.update(state) for key, value in slots.items(): setattr(self, key, value)
[docs] @classmethod def from_config( cls, path: AnyPath, extra_fetchers: Iterable[AnyPath] = (), ) -> Self: """Create a :class:`~id_translation.Translator` from TOML inputs. See :ref:`translator-config` for help. Args: path: Path to the main TOML configuration file. extra_fetchers: Paths to fetching configuration TOML files. If multiple fetchers are defined, they are ranked by input order. A fetcher defined in the main configuration is prioritized (rank=0). Returns: A new :class:`~id_translation.Translator` instance with a :attr:`~id_translation.Translator.config_metadata` attribute. """ # docs: https://id-translation.readthedocs.io/en/stable/documentation/translator-config.html factory = TranslatorFactory(path, extra_fetchers, cls) return factory.create() # type: ignore[return-value]
@property def config_metadata(self) -> meta.ConfigMetadata: """Return :func:`~id_translation.Translator.from_config` initialization :class:`metadata <id_translation.toml.meta.ConfigMetadata>`. Raises: ValueError: If this instance was not created by :meth:`~id_translation.Translator.from_config`, or copied from such an instance. """ if self._config_metadata is None: raise ValueError("Not created using Translator.from_config()") # pragma: no cover return self._config_metadata
[docs] def initialize_sources(self, task_id: int | None = None) -> Self: """Perform source discovery (fetcher initialization). This method does nothing if the ``Translator`` isn't :attr:`~id_translation.Translator.online`. Args: task_id: Used for logging. Returns: Self, for chained assignment. See Also: 🧵 Call this before sharing a ``Translator`` between threads; see :ref:`thread-safety`. """ if self.online: if task_id is None: task_id = _logging.generate_task_id() self.fetcher.initialize_sources(task_id) self._apply_fetcher_transformers() return self
[docs] def copy(self, **overrides: Unpack[CopyParams[NameType, SourceType, IdType]]) -> Self: """Make a copy of this :class:`~id_translation.Translator`. Args: overrides: Keyword arguments to use when instantiating the copy. Options that aren't given will be taken from the current instance. See the :class:`~id_translation.Translator` class documentation for possible choices (except `fetcher`; see Notes). Returns: A copy of this :class:`~id_translation.Translator` with `overrides` applied. Notes: User types are copied using :func:`copy.deepcopy`. The `fetcher` override selects how the current :attr:`~id_translation.Translator.fetcher` is carried over -- ``'keep'`` reuses it as-is, ``'copy'`` requires a successful :func:`~copy.deepcopy`, and the default, ``'auto'``, tries to clone and falls back to ``'keep'`` with a warning if that fails; see :attr:`~id_translation.translator_typing.FetcherCopyMode`. This only applies while :attr:`~id_translation.Translator.online`; an offline copy always shares the cached translation records with the original, which are never mutated in place. Fetcher-provided transformers already derived by this instance are carried over via the `transformers` argument, and are not derived again; see :meth:`Fetcher.get_transformer() <id_translation.fetching.Fetcher.get_transformer>`. This holds for all three `fetcher` modes. Passing `transformers` explicitly replaces that set wholesale -- including anything the fetcher provided -- and the copy does not query the fetcher for more. Pass :attr:`~id_translation.Translator.transformers` to keep the current set, or ``None`` to start empty and derive on first use, as the constructor does. .. deprecated:: 1.3.0 Passing anything but a :attr:`~id_translation.translator_typing.FetcherCopyMode` as `fetcher`. Use ``fetcher='keep'`` to reuse the current :attr:`~id_translation.Translator.fetcher`, which is what the failed-clone hint used to recommend. Replacing the data source will raise in ``id-translation==2.0.0``; construct a new :class:`~id_translation.Translator` instead. """ cls = type(self) fetcher_mode = self._pop_fetcher_copy_mode(overrides) kwargs: dict[str, Any] = { "fmt": self.fmt, "default_fmt": self.default_fmt, "default_fmt_placeholders": self._default_fmt_placeholders, "enable_uuid_heuristics": self.enable_uuid_heuristics, **overrides, } if "mapper" not in kwargs: kwargs["mapper"] = self.mapper.copy() if "fetcher" not in kwargs: # TODO(2.0.0): Unconditional; `fetcher` is a FetcherCopyMode only. kwargs["fetcher"] = self._copy_fetcher(fetcher_mode) if "transformers" not in kwargs: kwargs["transformers"] = self._copy_transformers() # Derived results travel with the copies; a second pass over the same fetcher would self-conflict. Only a # (deprecated) replacement is unqueried; `fetcher_mode` never substitutes the data source. settled = "fetcher" not in overrides and self._transformers_queried else: # An explicit set is complete, so it settles the question either way -- without this, the same call # would query or not depending on whether the caller had translated yet. `None` means "derive on first # use", exactly as it does in the constructor. settled = kwargs["transformers"] is not None new = cls(**kwargs) new._config_metadata = self._config_metadata new._transformers_queried = settled return new
def _pop_fetcher_copy_mode(self, overrides: CopyParams[NameType, SourceType, IdType]) -> FetcherCopyMode: """Resolve the `fetcher` override. A (deprecated) replacement data source is left in `overrides`.""" if "fetcher" not in overrides: return "auto" fetcher = overrides["fetcher"] if isinstance(fetcher, str): del overrides["fetcher"] return FETCHER_COPY_MODE_HELPER.check(fetcher) # TODO(2.0.0): Remove the rest; `fetcher` accepts only FetcherCopyMode. cls = type(self).__name__ current = self._fetcher if self.online else self._cached_tmap if fetcher is not None and fetcher is current: del overrides["fetcher"] emit_warning( f"Passing the current data source to {cls}.copy(fetcher=...) is deprecated; use fetcher='keep'." "\nWARNING: This will raise in `id-translation==2.0.0`.", FutureWarning, ) return "keep" emit_warning( f"{cls}.copy(fetcher=...) replaces the data source, which will be fixed for the lifetime of a {cls}." f"\nWARNING: This will raise in `id-translation==2.0.0`. Construct a new {cls} instead.", FutureWarning, ) return "auto" # Unused; the replacement stays in `overrides`. def _copy_fetcher( self, mode: FetcherCopyMode ) -> Fetcher[SourceType, IdType] | TranslationMap[NameType, SourceType, IdType]: if not self.online: return self.cache # Never mutated in place, so every mode shares it. if mode == "keep": return self.fetcher try: return deepcopy(self.fetcher) except TypeError as e: hint = f"{type(self).__name__}.copy(fetcher='keep')" if mode == "copy": e.add_note(f"Hint: Could not clone {tname(self.fetcher, include_module=True)}. Use {hint} to reuse it.") raise emit_warning( f"Failed to clone fetcher (TypeError: {e}). Caller instance will be reused." f"\nHint: To suppress this warning: {hint}" ) return self.fetcher def _copy_transformers(self) -> Transformers[SourceType, IdType]: try: # One `deepcopy` call shares one memo internally, so sharing is preserved at any depth for free. return deepcopy(self._transformers) except TypeError: pass # Retry per source below, to attribute the failure and reuse only what cannot be cloned. retval: Transformers[SourceType, IdType] = {} # Not a `deepcopy` memo: mapping a failed clone to its original there would splice the original into any # later copy that embeds it -- silently, and only for the registration orders where the failure came first. clones: dict[int, Transformer[IdType]] = {} for source, transformer in self._transformers.items(): if (clone := clones.get(id(transformer))) is None: try: clone = deepcopy(transformer) except TypeError as e: cls = self.__class__.__name__ emit_warning( f"Failed to clone the transformer for {source!r} (TypeError: {e})." " Caller instance will be reused." f"\nHint: {cls}.copy(transformers={cls}.transformers) also disables fetcher-provided" " discovery on the copy; see Translator.copy().", ) clone = transformer clones[id(transformer)] = clone retval[source] = clone return retval if TYPE_CHECKING: @overload def translate( self, translatable: InplaceTranslatable[NameType, IdType], names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, # https://github.com/python/mypy/issues/7333#issuecomment-788255229 copy: Literal[False], ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, # Translation specification reverse: bool = False, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> None: ... @overload def translate( self, translatable: CopyTranslatable[IdType], names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, # https://github.com/python/mypy/issues/7333#issuecomment-788255229 copy: Literal[False], ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, # Translation specification reverse: bool = False, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> NoReturn: ... @overload def translate( self, translatable: IdTypes, names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, copy: Literal[True] = True, ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, # Translation specification reverse: bool = False, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> str: ... @overload def translate( self, translatable: list[IdType], names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, copy: Literal[True] = True, ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, # Translation specification reverse: bool = False, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> list[str]: ... @overload def translate( self, translatable: set[IdType], names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, copy: Literal[True] = True, ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, # Translation specification reverse: bool = False, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> set[str]: ... @overload def translate( self, # This is not correct, but using the TypeVar (which the user may bind to get proper typing) doesn't work # at this time (python 3.11.3, mypy 1.5.1). Higher-Kinded TypeVars might solve this. # TODO: Higher-Kinded TypeVars translatable: DictToId[NameType, int] | DictToId[NameType, str] | DictToId[NameType, UUID], names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, copy: Literal[True] = True, ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, # Translation specification reverse: bool = False, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> DictToId[NameType, str]: ... @overload def translate( self, translatable: DictToSet[NameType, IdType], names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, copy: Literal[True] = True, ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, # Translation specification reverse: bool = False, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> DictToSet[NameType, str]: ... @overload def translate( self, translatable: DictToList[NameType, IdType], names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, copy: Literal[True] = True, ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, # Translation specification reverse: bool = False, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> DictToList[NameType, str]: ... @overload def translate( # Overlaps with DictToVarTuple self, translatable: DictToOneTuple[NameType, IdType], names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, copy: Literal[True] = True, ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, # Translation specification reverse: bool = False, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> DictToOneTuple[NameType, str]: ... @overload def translate( # Overlaps with DictToVarTuple self, translatable: DictToTwoTuple[NameType, IdType], names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, copy: Literal[True] = True, ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, # Translation specification reverse: bool = False, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> DictToTwoTuple[NameType, str]: ... @overload def translate( # Overlaps with DictToVarTuple self, translatable: DictToThreeTuple[NameType, IdType], names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, copy: Literal[True] = True, ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, # Translation specification reverse: bool = False, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> DictToThreeTuple[NameType, str]: ... @overload def translate( self, translatable: DictToVarTuple[NameType, IdType], names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, copy: Literal[True] = True, ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, # Translation specification reverse: bool = False, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> DictToVarTuple[NameType, str]: ... @overload def translate( self, translatable: tuple[IdType], names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, copy: Literal[True] = True, ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, # Translation specification reverse: bool = False, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> tuple[str]: ... @overload def translate( self, translatable: tuple[IdType, IdType], names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, copy: Literal[True] = True, ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, # Translation specification reverse: bool = False, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> tuple[str, str]: ... @overload def translate( self, translatable: tuple[IdType, IdType, IdType], names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, copy: Literal[True] = True, ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, # Translation specification reverse: bool = False, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> tuple[str, str, str]: ... @overload def translate( self, translatable: tuple[IdType, ...], names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, copy: Literal[True] = True, ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, # Translation specification reverse: bool = False, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> tuple[str, ...]: ... if ID_TRANSLATION_PANDAS_IS_TYPED: # pandas-stubs or similar @overload def translate( self, translatable: "pandas.DataFrame", names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, copy: Literal[True] = True, ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, # Translation specification reverse: bool = False, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> "pandas.DataFrame": ... @overload def translate( self, translatable: "pandas.Series[Any]", names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, copy: Literal[True] = True, ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, # Translation specification reverse: bool = False, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> "pandas.Series[str]": ... @overload def translate( self, translatable: "pandas.Index[Any]", names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, copy: Literal[True] = True, ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, # Translation specification reverse: bool = False, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> "pandas.Index[str]": ... @overload def translate( self, translatable: "pandas.DataFrame" | "pandas.Series[Any]", names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, copy: Literal[False], ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, # Translation specification reverse: bool = False, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> None: ... @overload def translate( self, translatable: "pandas.Index[Any]", names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, # https://github.com/python/mypy/issues/7333#issuecomment-788255229 copy: Literal[False], ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, # Translation specification reverse: bool = False, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> NoReturn: ... @overload def translate( self, translatable: Translatable[NameType, IdType], names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, ignore_names: Names[NameType] | None = None, copy: Literal[True] = True, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, reverse: Literal[False] = False, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> Translatable[NameType, str]: ... @overload def translate( self, translatable: Translatable[NameType, str], names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, ignore_names: Names[NameType] | None = None, copy: Literal[True] = True, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, reverse: Literal[True], fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> Translatable[NameType, IdType]: ...
[docs] def translate( self, translatable: Translatable[NameType, IdType], names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, ignore_names: Names[NameType] | None = None, copy: bool = True, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, reverse: bool = False, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> Translatable[NameType, str] | None: """Translate IDs to human-readable strings. Simplified process: 1. The :meth:`~id_translation.Translator.extract_names` method derives names of the `translatable` to translate (if needed). 2. The :meth:`~id_translation.Translator.map` method performs name-to-source mapping (see :class:`~id_translation.mapping.DirectionalMapping`). 3. The :meth:`~id_translation.Translator.fetch` method extracts IDs to translate and retrieves data (see :class:`~id_translation.offline.TranslationMap`). 4. Finally, the ``translate()`` method (i.e. this one) applies the translations and returns to the caller. See the :ref:`translation-primer` page for a detailed process description. Args: translatable: A data structure to translate. names: Explicit names to translate. Derive from `translatable` if ``None``. Alternatively, you may pass a ``dict`` on the form ``{name_in_translatable: source_to_use}``. ignore_names: Names **not** to translate, or a predicate ``(NameType) -> bool``. copy: If ``False``, translate in-place and return ``None``. override_function: A callable ``(name, sources, context) -> Source | None``, where `context` is always ``None``. See :meth:`Mapper.apply <id_translation.mapping.Mapper.apply>` for details. max_fails: The maximum fraction of IDs for which translation may fail. 1=disabled. Missing IDs (e.g. ``NaN``) in ``pandas`` types are pass-throughs and never count; a ``None`` in a builtin collection is an ordinary -- unknown -- ID, and does. reverse: If ``True``, perform translations back to IDs. Offline mode only. fmt: A :class:`format string <id_translation.offline.Format>` such as **'{id}:{name}'** to use. Default is :attr:`Translator.fmt <id_translation.Translator.fmt>`. io_kwargs: Keyword arguments for the IO class (e.g. :class:`~id_translation.dio.integration.pandas.PandasIO`). Returns: A translated copy of `translatable` if ``copy=True``, otherwise ``None``. Examples: Manual :ref:`name-to-source <name-to-source-mapping>` mapping with a temporary name-only :class:`~id_translation.offline.Format`. .. # Hidden setup code >>> translator = Translator({"animals": {"id": [2], "name": ["Simba"]}}) >>> n2s = {"lions": "animals", "big_cats": "animals"} >>> translator.translate({"lions": 2, "big_cats": 2}, names=n2s, fmt="{name}") {'lions': 'Simba', 'big_cats': 'Simba'} Name mappings must be complete; any name not present in the keys will be ignored (left as-is). Raises: ~id_translation.dio.exceptions.UntranslatableTypeError: If ``type(translatable)`` cannot be translated. ~id_translation.exceptions.MissingNamesError: If `names` are not given and cannot be derived from `translatable`. ~id_translation.mapping.exceptions.UnmappedExplicitNamesError: If any required (explicitly given) names fail to map to a source. ~id_translation.mapping.exceptions.MappingError: If name-to-source mapping fails, e.g. :class:`~id_translation.mapping.exceptions.ScoringDisabledError` for a name that is neither a source nor overridden, when using the default :class:`~id_translation.mapping.Mapper`. ~id_translation.exceptions.MissingPlaceholderError: If a source lacks a placeholder that `fmt` requires. ValueError: If `max_fails` is not a valid fraction. ~id_translation.exceptions.TooManyFailedTranslationsError: If translation fails for more than `max_fails` of IDs. ~id_translation.exceptions.ConnectionStatusError: If ``reverse=True`` while the :class:`~id_translation.Translator` is online. ~id_translation.mapping.exceptions.UserMappingError: If `override_function` returns a source which is not known, and ``mapper.on_unknown_user_override='raise'``. See Also: 🔑 This is a key event method. See :ref:`key-events` for details. The :envvar:`ID_TRANSLATION_DISABLED` variable. """ if read_bool(ID_TRANSLATION_DISABLED): message = f"Translation aborted; {ID_TRANSLATION_DISABLED} is set." LOGGER.warning(message) emit_warning(message, category=TranslationDisabledWarning, logged=True) return translatable if copy else None # Return unchanged; breaks typing. if self.online and reverse: # pragma: no cover raise ConnectionStatusError("Reverse translation cannot be performed online.") task_id = _logging.generate_task_id() self.initialize_sources(task_id) task = TranslationTask( self, translatable, self._fmt if fmt is None else Format.parse(fmt), names, ignore_names=ignore_names, override_function=override_function, copy=copy, max_fails=max_fails, reverse=reverse, enable_uuid_heuristics=self._enable_uuid_heuristics, io_kwargs=io_kwargs, event_key=f"{type(self).__name__}.{self.translate.__name__}", task_id=task_id, ) task.log_key_event_enter() translation_map = self._get_updated_tmap(task) if not translation_map: # Return unchanged; this is technically against the API spec. If the user has required translation to # success through configuration, exceptions will be raised elsewhere. I don't know how to express this using # the Python type system. return translatable if copy else None task.verify(translation_map) ans = task.insert(translation_map) self._translated_names = (task_id, dict(task.name_to_source)) task.finished() task.log_key_event_exit() return ans
@overload def translated_names(self, with_source: Literal[True]) -> NameToSource[NameType, SourceType]: ... @overload def translated_names(self, with_source: Literal[False] = False) -> list[NameType]: ...
[docs] def translated_names(self, with_source: bool = True) -> NameToSource[NameType, SourceType] | list[NameType]: """Return the names that were translated by the most recent :meth:`~id_translation.Translator.translate`-call. Args: with_source: If ``True``, return a dict ``{name: source}`` instead of a list. Returns: Recent names translated by this :class:`~id_translation.Translator`, in **arbitrary** order. Raises: ValueError: If no names have been translated using this :class:`~id_translation.Translator`. Notes: 🧵 This method is not thread safe. See :ref:`thread-safety` for details. """ if self._translated_names is None: raise ValueError("No names have been translated using this Translator.") task_id, name_to_source = self._translated_names LOGGER.debug(f"Returning names translated by task with {task_id=}.", extra=dict(task_id=task_id)) return dict(name_to_source) if with_source else list(name_to_source)
@overload def extract_names( self, translatable: Translatable[NameType, IdType], *, ignore_names: Names[NameType] | None = None, io_kwargs: Mapping[str, Any] | None = None, raising: Literal[False] = False, ) -> list[NameType] | None: ... @overload def extract_names( self, translatable: Translatable[NameType, IdType], *, ignore_names: Names[NameType] | None = None, io_kwargs: Mapping[str, Any] | None = None, raising: Literal[True], ) -> list[NameType]: ...
[docs] def extract_names( self, translatable: Translatable[NameType, IdType], *, ignore_names: Names[NameType] | None = None, io_kwargs: Mapping[str, Any] | None = None, raising: bool = False, ) -> list[NameType] | None: """Extract names in `translatable`. Perform name extraction. The `names` are the first input to the :meth:`~id_translation.Translator.map` method, with the second being the :attr:`~id_translation.Translator.sources`. Does not call :meth:`~id_translation.Translator.initialize_sources`. Args: translatable: A data structure to map names for. ignore_names: Names **not** to translate, or a predicate ``(NameType) -> bool``. io_kwargs: Keyword arguments for the IO class (e.g. :class:`~id_translation.dio.integration.pandas.PandasIO`). raising: If ``False`` (the default), return ``None`` instead of raising :class:`~id_translation.exceptions.MissingNamesError` when name extraction fails. Returns: Names to map to sources. Raises: ~id_translation.exceptions.MissingNamesError: If `names` cannot be derived (only when ``raising=True``). """ task = NamesTask(self, translatable, ignore_names=ignore_names, io_kwargs=io_kwargs) if raising: return task.mapper_input_names try: return task.mapper_input_names except MissingNamesError: return None
[docs] def map( self, translatable: Translatable[NameType, IdType], names: NameTypes[NameType] | None = None, *, ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> NameToSource[NameType, SourceType]: """Map names to translation sources. Args: translatable: A data structure to map names for. names: Explicit names to translate. Derive from `translatable` if ``None``. ignore_names: Names **not** to translate, or a predicate ``(NameType) -> bool``. override_function: A callable ``(name, sources, context) -> Source | None``, where `context` is always ``None``. See :meth:`Mapper.apply <id_translation.mapping.Mapper.apply>` for details. io_kwargs: Keyword arguments for the IO class (e.g. :class:`~id_translation.dio.integration.pandas.PandasIO`). Returns: A mapping of names to translation sources. Raises: ~id_translation.exceptions.MissingNamesError: If `names` are not given and cannot be derived from `translatable`. ~id_translation.mapping.exceptions.UnmappedExplicitNamesError: If any required (explicitly given) names fail to map to a source. ~id_translation.mapping.exceptions.MappingError: If name-to-source mapping fails, e.g. :class:`~id_translation.mapping.exceptions.ScoringDisabledError` for a name that is neither a source nor overridden, when using the default :class:`~id_translation.mapping.Mapper`. ~id_translation.mapping.exceptions.UserMappingError: If `override_function` returns a source which is not known, and ``mapper.on_unknown_user_override='raise'``. See Also: 🔑 This is a key event method. See :ref:`key-events` for details. The :meth:`~id_translation.Translator.extract_names` method. """ task_id = _logging.generate_task_id() self.initialize_sources(task_id) return MappingTask( self, translatable, names, ignore_names=ignore_names, override_function=override_function, io_kwargs=io_kwargs, task_id=task_id, ).name_to_source
[docs] def map_scores( self, translatable: Translatable[NameType, IdType], names: NameTypes[NameType] | None = None, *, ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> ScoreMatrix[NameType, SourceType]: """Returns raw match scores for name-to-source mapping. See :meth:`~id_translation.Translator.map` for details.""" return MappingTask( self, translatable, names, ignore_names=ignore_names, override_function=override_function, io_kwargs=io_kwargs, ).compute_scores()
@property def sources(self) -> list[SourceType]: """A list of known source names. Sources are determined either by the :attr:`~id_translation.Translator.fetcher` or the :attr:`~id_translation.Translator.cache`. """ if not self.online and self._cached_tmap is not None: # Not by way of `placeholders`: that property copies every per-source placeholder list. return self._cached_tmap.sources return list(self.placeholders) @property def placeholders(self) -> Mapping[SourceType, Sequence[str]]: """A mapping ``{source: [placeholders, ...]}``. Placeholders shown here are the names as they appear **in the source**. """ if self.online: return self.fetcher.placeholders assert self._cached_tmap is not None, "bad internal state" # noqa: S101 return self._cached_tmap.placeholders @property def fmt(self) -> Format: """Main translation :class:`~id_translation.offline.Format` for this ``Translator`` instance.""" return self._fmt @property def default_fmt(self) -> Format: """Alternative translation :class:`~id_translation.offline.Format`, used for unknown IDs.""" return self._default_fmt @property def enable_uuid_heuristics(self) -> bool: """Improves matching when :py:class:`~uuid.UUID`-like IDs are in use.""" return self._enable_uuid_heuristics @property def online(self) -> bool: """Return connectivity status. If ``False``, no new translations may be fetched.""" return self._fetcher is not None @property def fetcher(self) -> Fetcher[SourceType, IdType]: """Return the :class:`~id_translation.fetching.Fetcher` instance used to retrieve translations.""" # Not `self.online`: narrowing the attribute here is what lets the rest of the class use this property # without a guard of its own. if self._fetcher is None: raise ConnectionStatusError( "Cannot fetch new translations.", hints="Use the Translator.cache-property to access the data.", ) return self._fetcher @property def mapper(self) -> Mapper[NameType, SourceType, None]: """Return the :class:`~id_translation.mapping.Mapper` instance used for name-to-source binding.""" return self._mapper @property def cache(self) -> TranslationMap[NameType, SourceType, IdType]: """Return a :class:`~id_translation.offline.TranslationMap` of cached translations.""" if self._cached_tmap is None: assert self.online, "bad internal state" # noqa: S101 msg = f"No cache available. Use `{self.go_offline.__qualname__}` to cache translations." raise RuntimeError(msg) return self._cached_tmap
[docs] @classmethod def load_persistent_instance( cls, cache_dir: AnyPath, config_path: AnyPath, extra_fetchers: Iterable[AnyPath] = (), max_age: str | timedelta | None = "12h", on_config_changed: Literal["raise", "recreate"] = "recreate", ) -> Self: """Load or create a persistent, offline :class:`~id_translation.Translator`. Instances are created, stored and loaded as determined by a metadata file located in the given `cache_dir`. A new :class:`~id_translation.Translator` will be created if: * There is no `'metadata'` file, or * the original :class:`~id_translation.Translator` is too old (see `max_age`), or * the current configuration, as defined by `config_path` and `extra_fetchers`, has changed in such a way that it is no longer equivalent to the configuration used to create the original :class:`~id_translation.Translator`. Equivalence also covers the ``Translator`` class, ``metaconf.toml``, and the Python and package versions, so upgrading ``id-translation`` counts as a change. For details, see :class:`~id_translation.toml.meta.ConfigMetadata`. Args: cache_dir: Root directory where the cached translator and associated metadata is stored. config_path: Path to the main TOML configuration file. extra_fetchers: Paths to fetching configuration TOML files. If multiple fetchers are defined, they are ranked by input order. A fetcher defined in the main configuration is prioritized (rank=0). max_age: The maximum age of the cached :class:`~id_translation.Translator` before it must be recreated. Pass ``'0s'`` or ``timedelta(0)`` to force recreation, or ``None`` to ignore. on_config_changed: One of ``raise|recreate``. If ``'raise'``, crash instead of creating a new instance if the configuration (as determined by `config_path` and `extra_fetchers`) has changed, including after a version upgrade. Returns: A new or cached :class:`~id_translation.Translator` instance with a :attr:`~id_translation.Translator.config_metadata` attribute. Raises: ~id_translation.exceptions.ConfigurationChangedError: If the configuration has changed and ``on_config_changed='raise'``. Notes: 🧵 This method is not thread safe. See :ref:`thread-safety` for details. See Also: The :meth:`~id_translation.Translator.from_config` method, which will read the `config_path`. """ path = any_path_to_path(config_path) cache_dir = any_path_to_path(cache_dir).expanduser().absolute() cache_dir.mkdir(parents=True, exist_ok=True) metadata_path = cache_dir / "metadata.json" cache_path = cache_dir / "translator.pkl" extra_fetcher_paths = list(map(str, extra_fetchers)) metadata = meta.ConfigMetadata.from_toml_paths( str(path), extra_fetcher_paths, clazz=cls, ) use_cached, reason, reason_type = metadata.use_cached(metadata_path, max_age) if use_cached: LOGGER.info(f"Reuse existing Translator; {reason}. Cache dir: '{cache_dir}'.") return cls.restore(cache_path) if reason_type == "metadata-changed" and on_config_changed.lower() == "raise": raise ConfigurationChangedError(reason) LOGGER.info(f"Create new Translator; {reason}. Cache dir: '{cache_dir}'.") translator = cls.from_config(path, extra_fetcher_paths) translator.go_offline(path=cache_path) metadata_path.write_text(translator.config_metadata.to_json(), encoding="utf-8") return translator
[docs] @classmethod def restore(cls, path: AnyPath) -> Self: """Restore a serialized :class:`~id_translation.Translator`. Args: path: Path to a serialized :class:`~id_translation.Translator`. Returns: A :class:`~id_translation.Translator`. Raises: TypeError: If the object at `path` is not a :class:`~id_translation.Translator` or a subtype thereof. See Also: The :meth:`~id_translation.Translator.go_offline` method. """ full_path = any_path_to_path(path).expanduser() with full_path.open("rb") as f: ans = pickle.load(f) # noqa: S301 if type(ans) is not cls: # pragma: no cover raise TypeError(f"Serialized object at '{full_path}' is a {type(ans)}, not {cls}.") if LOGGER.isEnabledFor(logging.DEBUG): extra = "" if ans._config_metadata is None else f" with {ans.config_metadata}" LOGGER.debug(f"Deserialized {ans}{extra}.") return ans
[docs] def go_offline( self, translatable: Translatable[NameType, IdType] | None = None, names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, path: AnyPath | None = None, ) -> Self: """Retrieve and :attr:`~id_translation.Translator.cache` translations in memory. .. warning:: The :attr:`~id_translation.Translator.fetcher` will be destroyed. Args: translatable: Data from which IDs to fetch will be extracted. Fetch all IDs if ``None``. names: Explicit names to translate. Derive from `translatable` if ``None``. ignore_names: Names **not** to translate, or a predicate ``(NameType) -> bool``. override_function: A callable ``(name, sources, context) -> Source | None``, where `context` is always ``None``. See :meth:`Mapper.apply <id_translation.mapping.Mapper.apply>` for details. Ignored when `translatable` is given. max_fails: The maximum fraction of IDs for which translation may fail. 1=disabled. Missing IDs (e.g. ``NaN``) in ``pandas`` types are pass-throughs and never count; a ``None`` in a builtin collection is an ordinary (unknown) ID, and does. fmt: A :class:`format string <id_translation.offline.Format>` such as **'{id}:{name}'** to use. Default is :attr:`Translator.fmt <id_translation.Translator.fmt>`. The snapshot is only guaranteed to hold the placeholders of `fmt` when `translatable` is given, and those of `Translator.fmt` otherwise; list any others that a later `translate()` needs there as optional blocks, e.g. ``'{id}:{name}[ {team}]'``. io_kwargs: Keyword arguments for the IO class (e.g. :class:`~id_translation.dio.integration.pandas.PandasIO`). path: If given, serialize the :class:`~id_translation.Translator` to disk after retrieving data. Returns: Self, for chained assignment. Raises: ~id_translation.exceptions.ConnectionStatusError: If already disconnected from the fetcher, i.e. not :attr:`~id_translation.Translator.online`. ~id_translation.fetching.exceptions.ForbiddenOperationError: If :meth:`Fetcher.fetch_all <id_translation.fetching.Fetcher.fetch_all>` is disabled and ``translatable=None``. ValueError: If `io_kwargs` is given without `translatable`. ~id_translation.mapping.exceptions.MappingError: If :meth:`~id_translation.Translator.map` fails (only when `translatable` is given). Notes: 🧵 This method is not thread safe. See :ref:`thread-safety` for details. The :class:`~id_translation.Translator` is guaranteed to be :func:`~rics.misc.serializable` once offline. Fetchers often aren't as they require things like database connections to function. See Also: 🔑 This is a key event method. See :ref:`key-events` for details. The :meth:`~id_translation.Translator.restore` method (when `path` is set). """ if not self.online: raise ConnectionStatusError("Cannot fetch new translations.") start = perf_counter() task_id = _logging.generate_task_id(start) self.initialize_sources(task_id) if LOGGER.isEnabledFor(logging.DEBUG): LOGGER.debug( msg=f"Begin going offline with {len(self.sources)} sources provided by: {self.fetcher}", extra=dict( task_id=task_id, event_key=_logging.get_event_key(self.go_offline, "enter"), # Task-specific path=None if path is None else str(path), translatable_type=None if translatable is None else tname(translatable, include_module=True), ), ) translation_map = self._user_fetch( translatable, names, ignore_names=ignore_names, override_function=override_function, max_fails=max_fails, fmt=fmt, io_kwargs=io_kwargs, func=self.go_offline.__qualname__, task_id=task_id, ) self.fetcher.close() self._fetcher = None self._cached_tmap = translation_map # This produces the same kind of snapshot as building from one directly; see the `TranslationMap` branch in # `__init__`. The registrations up to this point may cover only a subset of sources on purpose. self._unverified_sources.clear() if path: path = any_path_to_path(path).expanduser() path.parent.mkdir(parents=True, exist_ok=True) with path.open("wb") as f: pickle.dump(self, f) cls = tname(self, include_module=True) LOGGER.info( f"Serialized '{cls}' of size {path.stat().st_size / 2**20:.2g} MiB at path='{path}'.", extra=dict( task_id=task_id, event_key=_logging.get_event_key(self.go_offline, "serialize"), # Task-specific path=str(path), translatable_type=None if translatable is None else tname(translatable, include_module=True), ), ) if LOGGER.isEnabledFor(logging.INFO): seconds = perf_counter() - start LOGGER.info( f"Went offline with {len(translation_map.sources)} sources in {fmt_sec(seconds)}: {translation_map}.", extra=dict( task_id=task_id, event_key=_logging.get_event_key(self.go_offline, "exit"), seconds=seconds, # Task-specific path=None if path is None else str(path), translatable_type=None if translatable is None else tname(translatable, include_module=True), ), ) return self
[docs] def fetch( self, translatable: Translatable[NameType, IdType] | None = None, names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, ) -> TranslationMap[NameType, SourceType, IdType]: """Fetch translations. Calling ``fetch`` without arguments will perform a :meth:`Fetcher.fetch_all <id_translation.fetching.Fetcher.fetch_all>` -operation, without going offline. The returned :class:`~id_translation.offline.TranslationMap` may be converted to native types with :meth:`TranslationMap.to_dicts <id_translation.offline.TranslationMap.to_dicts>`. Args: translatable: A data structure to translate. names: Explicit names to translate. Derive from `translatable` if ``None``. Alternatively, you may pass a ``dict`` on the form ``{name_in_translatable: source_to_use}``. ignore_names: Names **not** to translate, or a predicate ``(NameType) -> bool``. override_function: A callable ``(name, sources, context) -> Source | None``, where `context` is always ``None``. See :meth:`Mapper.apply <id_translation.mapping.Mapper.apply>` for details. Ignored when `translatable` is given. max_fails: The maximum fraction of IDs for which translation may fail. 1=disabled. Missing IDs (e.g. ``NaN``) in ``pandas`` types are pass-throughs and never count; a ``None`` in a builtin collection is an ordinary (unknown) ID, and does. fmt: A :class:`format string <id_translation.offline.Format>` such as **'{id}:{name}'** to use. Default is :attr:`Translator.fmt <id_translation.Translator.fmt>`. io_kwargs: Keyword arguments for the IO class (e.g. :class:`~id_translation.dio.integration.pandas.PandasIO`). Returns: A :class:`~id_translation.offline.TranslationMap`. Raises: ~id_translation.exceptions.ConnectionStatusError: If disconnected from the fetcher, i.e. not :attr:`~id_translation.Translator.online`. ValueError: If `io_kwargs` is given without `translatable`. Examples: Using the returned :class:`~id_translation.offline.TranslationMap` class. .. # Hidden setup code >>> from id_translation.fetching import MemoryFetcher >>> translation_data = { ... "animals": {"id": [0, 1, 2], "name": ["Tarzan", "Morris", "Simba"]}, ... "people": {"id": [1999, 1991], "name": ["Sofia", "Richard"]}, ... } >>> translator = Translator(MemoryFetcher(translation_data)) >>> translation_map = translator.fetch() >>> translation_map TranslationMap('animals': 3 IDs, 'people': 2 IDs) **Convert to finished translations.** * :meth:`TranslationMap.to_translations <id_translation.offline.TranslationMap.to_translations>` → ``{source: MagicDict}``, where a :class:`~id_translation.offline.MagicDict` is similar to a regular ``dict[IdType, str]``-type dict. >>> people = translation_map.to_translations()["people"] >>> people {1999: '1999:Sofia', 1991: '1991:Richard'} .. warning:: The :class:`~id_translation.offline.MagicDict` class is used internally and has a few important differences from the built-in type. Please refer to the :class:`~id_translation.offline.MagicDict` class documentation for details. To convert a :class:`~id_translation.offline.MagicDict` to a regular ``dict``, simply use the dict constructor: >>> dict(people) {1999: '1999:Sofia', 1991: '1991:Richard'} **Convert to raw translation data.** * :meth:`TranslationMap.to_pandas <id_translation.offline.TranslationMap.to_pandas>` → ``{source: DataFrame}`` * :meth:`TranslationMap.to_dicts <id_translation.offline.TranslationMap.to_dicts>` → ``{source: {placeholder: [values...]}}`` >>> translation_map.to_dicts()["people"] {'id': [1999, 1991], 'name': ['Sofia', 'Richard']} """ task_id = _logging.generate_task_id() self.initialize_sources(task_id) return self._user_fetch( translatable, names, ignore_names=ignore_names, override_function=override_function, max_fails=max_fails, fmt=fmt, io_kwargs=io_kwargs, func=self.fetch.__qualname__, task_id=task_id, )
def _user_fetch( self, translatable: Translatable[NameType, IdType] | None = None, names: NameTypes[NameType] | NameToSource[NameType, SourceType] | None = None, *, ignore_names: Names[NameType] | None = None, override_function: UserOverrideFunction[NameType, SourceType, None] | None = None, max_fails: float = 1.0, fmt: FormatType | None = None, io_kwargs: Mapping[str, Any] | None = None, func: str, task_id: int | None = None, ) -> TranslationMap[NameType, SourceType, IdType]: if io_kwargs and translatable is None: raise ValueError(f"Got {io_kwargs=}, but {translatable=}; io_kwargs requires a translatable.") fmt = self._fmt if fmt is None else Format.parse(fmt) if translatable is None: if all(p is None for p in (names, ignore_names, override_function)): source_translations = self._fetch(None, task_id=task_id) else: dummy = {source: None for source in self.sources} sources = self.map(dummy, names, ignore_names=ignore_names, override_function=override_function) source_translations = self._fetch(set(sources.values()), task_id=task_id) translation_map = self._to_translation_map(source_translations, fmt=fmt) if names is None: pass # Callers must perform mapping unless name=source. elif isinstance(names, dict): translation_map.name_to_source = names else: translation_map.name_to_source = self.mapper.apply( as_list(names), translation_map.sources, override_function=override_function, task_id=task_id, ).flatten() else: task = TranslationTask( self, translatable, fmt, names, ignore_names=ignore_names, max_fails=max_fails, enable_uuid_heuristics=self._enable_uuid_heuristics, io_kwargs=io_kwargs, event_key=func, task_id=task_id, ) translation_map = self._get_updated_tmap(task, force_fetch=True) task.verify(translation_map) return translation_map def _get_updated_tmap( self, task: TranslationTask[NameType, SourceType, IdType], force_fetch: bool = False, ) -> TranslationMap[NameType, SourceType, IdType]: """Get an updated translation map.""" if not task.name_to_source: source_translations = {} # Nothing to translate. elif force_fetch or self._cached_tmap is None: source_translations = self._execute_fetch(task) else: source_translations = self._cached_tmap._extract_translations() return self._to_translation_map(source_translations, task.fmt, task.name_to_source) @property def transformers(self) -> Transformers[SourceType, IdType]: """Get a dict ``{source: transformer}``. The ``Translator`` is the sole owner of transformers; see :meth:`~id_translation.Translator.register_transformer` to add them. .. note:: Mutating the returned dict bypasses chain normalization and is **deprecated**; this will become a read-only view in ``2.0.0``. Use :meth:`~id_translation.Translator.register_transformer` instead. Returns: A dict with one transformer per source. Chains are a single :class:`~id_translation.transform.TransformerStack`. """ # TODO(2.0.0): Return MappingProxyType(self._transformers); returning the live dict is a 1.x compatibility # concession, since callers may hold it or mutate it. return self._transformers
[docs] def register_transformer( self, source: SourceType, transformer: Transformer[IdType] | Sequence[Transformer[IdType]], *, on_existing: OnExistingTransformer = "raise", ) -> None: """Register `transformer` for `source`. Creates a :class:`~id_translation.transform.TransformerStack` when called multiple times with ``on_existing='append'``. A warning is emitted if duplicates are detected. The fetcher may :meth:`provide <id_translation.fetching.Fetcher.get_transformer>` transformers of its own. The first :meth:`~id_translation.Translator.initialize_sources` call queries each discovered source and chains what it gets ahead of anything registered here, so a call made after that point sees the whole chain -- and ``on_existing='overwrite'`` replaces it. Args: source: The source to transform. transformer: A :class:`~id_translation.transform.types.Transformer`, or a sequence of them to chain in the given order. on_existing: Action to take when `source` already has a transformer. Use ``'append'`` to chain onto it, or ``'overwrite'`` to discard it. Raises: ValueError: If `transformer` is an empty sequence. ~id_translation.exceptions.TransformerConflictError: If `source` is already registered and ``on_existing='raise'``. """ self._register_transformer(source, transformer, on_existing)
def _register_transformer( self, source: SourceType, transformer: Transformer[IdType] | Sequence[Transformer[IdType]], on_existing: OnExistingTransformer, ) -> None: # The implementation of `register_transformer`, which subclasses may override. `__init__` calls this instead, # since an override is entitled to touch attributes that do not exist until construction finishes. on_existing = ON_EXISTING_HELPER.check(on_existing) new = as_transformer(transformer) registered = self._transformers.get(source) if registered is not None: if on_existing == "append": new = TransformerStack(registered, new) # May warn about a duplicate -- before anything is committed. elif on_existing == "raise": exc = TransformerConflictError(f"Transformer {registered!r} is already registered for {source!r}.") exc.add_note(_ON_EXISTING_CONFLICT_HINT) raise exc self._transformers[source] = new self._unverified_sources.append(source) if self._cached_tmap is not None and not self.online: # Offline sources are fixed and already known, so verify immediately; online instances are verified by # initialize_sources(). Both are None while __init__ registers, where the sources are not yet reachable -- # inline-data registrations are verified at the end of __init__ instead, and a snapshot's never are. try: self._warn_on_transformers_for_unknown_sources() except Exception: # Promoted to an error (e.g. under `-W error`): the caller sees a raise, so the registration must not # stick -- retrying the apparently-failed call would otherwise conflict with it. if registered is None: del self._transformers[source] else: self._transformers[source] = registered self._unverified_sources.pop() # Ours is last: a raised check never drains the queue. raise # The cache's appliers captured their transformers when it was built; keep them in sync so that # `Translator.cache` and `translate()` agree about the registration. self._cached_tmap._set_transformer(source, new) def _apply_fetcher_transformers(self) -> None: if not self._transformers_queried: # Every pass is derived from current truth (fetcher answer, registrations, declared intent); a failed # pass mutates nothing and the next one simply asks again. No cached answer exists to go stale. self._register_transformers_from_fetcher(self._collect_provided()) self._transformers_queried = True self._warn_on_transformers_for_unknown_sources() def _collect_provided(self) -> Transformers[SourceType, IdType]: """Query every source, applying nothing: a raising fetcher must leave the instance untouched.""" provided: Transformers[SourceType, IdType] = {} for source in self.sources: try: transformer = self.fetcher.get_transformer(source) if transformer is not None: provided[source] = as_transformer(transformer) # A bad answer raises before anything registers. except Exception as e: e.add_note(f" - Raised by Fetcher.get_transformer({source!r}) on fetcher={self.fetcher}.") raise return provided def _register_transformers_from_fetcher(self, transformers: Transformers[SourceType, IdType]) -> None: backup = self._transformers.copy() mark = len(self._unverified_sources) # Append-only below, so this rewinds the queue to where it started. try: for source, provided in transformers.items(): registered = self._transformers.get(source) if registered is None: chain = provided else: # Member-wise: the answer may be a sequence, of which only part is already registered. members = provided.transformers if isinstance(provided, TransformerStack) else (provided,) fresh = [it for it in members if not has_member(registered, it)] if not fresh: continue chain = as_transformer([*fresh, registered]) self.register_transformer(source, chain, on_existing="overwrite") except Exception as e: # An overridden `register_transformer`, or a promoted duplicate warning out of `as_transformer`. # Roll back either way, preserving the dict identity `Translator.transformers` hands out. self._transformers.clear() self._transformers.update(backup) del self._unverified_sources[mark:] e.add_note(f" - Raised while registering a transformer provided by fetcher={self.fetcher}.") raise def _warn_on_transformers_for_unknown_sources(self) -> None: # This runs per translate, so do no work unless something has been registered since the last check. if not self._unverified_sources: return # Nothing below may drain the queue early: neither a discovery that raises nor a warning that `-W error` # promotes may pass a registration off as checked, since the misconfiguration would then vanish on the # next call. sources = self.sources # The TestFetcher installed when no fetcher is given reports none, but serves every source. A fetcher that # reports none because it has none is the case where every registration is unreachable. serves_everything = not sources and isinstance(self._fetcher, TestFetcher) if not serves_everything: known = set(sources) # Hoisted: the `if` below is evaluated once per queued registration. # Deduplicated in registration order: `on_existing='append'` queues the same source once per call. if unknown := list(dict.fromkeys(s for s in self._unverified_sources if s not in known)): msg = ( f"Transformers registered for unknown sources: {unknown}. These will never be applied." f"\nHint: Known sources: {sources}." ) if unresolved := self._unresolved_children(): # A `[transform]`-section outlives a fetcher discarded during discovery, so these may be its # sources rather than a typo. Cannot be told apart: a child that raised never reported what it # serves. msg += ( f"\nNote: source discovery raised for {len(unresolved)} optional fetcher(s), so what they" f" would have served is unknown: {unresolved}." ) emit_warning(msg) self._unverified_sources.clear() def _unresolved_children(self) -> list[str]: return self._fetcher._unresolved_children() if isinstance(self._fetcher, MultiFetcher) else [] def _execute_fetch( self, task: TranslationTask[NameType, SourceType, IdType], ) -> SourcePlaceholderTranslations[SourceType]: start = perf_counter() ids_to_fetch = [IdsToFetch(source, ids=ids) for source, ids in task.extract_ids().items()] for itf in ids_to_fetch: transformer = self._transformers.get(itf.source) if transformer is not None: transformer.update_ids(itf.ids) source_translations = self._fetch(ids_to_fetch, fmt=task.fmt, task_id=task.task_id) task.add_timing("fetch", perf_counter() - start) return source_translations def _fetch( self, ids_or_sources: list[IdsToFetch[SourceType, IdType]] | set[SourceType] | None, fmt: Format | None = None, task_id: int | None = None, ) -> SourcePlaceholderTranslations[SourceType]: fmt = fmt or self._fmt placeholders = fmt.placeholders placeholder_attributes = fmt.placeholder_attributes required = fmt.required_placeholders if self._default_fmt and ID in self._default_fmt.placeholders and ID not in placeholders: # Ensure that default translations can always use the ID placeholders = (*placeholders, ID) required = (*required, ID) if task_id is None: task_id = _logging.generate_task_id() if ids_or_sources is None or isinstance(ids_or_sources, set): return self.fetcher.fetch_all( placeholders, required=required, placeholder_attributes=placeholder_attributes, sources=ids_or_sources, task_id=task_id, enable_uuid_heuristics=self._enable_uuid_heuristics, ) else: return self.fetcher.fetch( ids_or_sources, placeholders, required=required, placeholder_attributes=placeholder_attributes, task_id=task_id, enable_uuid_heuristics=self._enable_uuid_heuristics, ) def _to_translation_map( self, source_translations: SourcePlaceholderTranslations[SourceType], fmt: Format | None = None, name_to_source: NameToSource[NameType, SourceType] | None = None, ) -> TranslationMap[NameType, SourceType, IdType]: return TranslationMap( source_translations, fmt=fmt or self._fmt, default_fmt=self._default_fmt, name_to_source=name_to_source, default_fmt_placeholders=self._default_fmt_placeholders, enable_uuid_heuristics=self._enable_uuid_heuristics, transformers=self._transformers, ) def __repr__(self) -> str: more = f"fetcher={self.fetcher}" if self.online else f"cache={self.cache}" online = self.online return f"{tname(self)}({online=}: {more})"
def _handle_default( default_fmt: FormatType, default_fmt_placeholders: MakeType[SourceType, str, Any] | None, ) -> tuple[InheritedKeysDict[SourceType, str, Any], Format]: default_fmt = Format.parse(default_fmt) if not default_fmt_placeholders: return InheritedKeysDict(), default_fmt if isinstance(default_fmt_placeholders, InheritedKeysDict): default_placeholders = default_fmt_placeholders else: default_placeholders = InheritedKeysDict.make(default_fmt_placeholders) return default_placeholders, default_fmt