В процессе разработки часто приходится использовать словари для получения значения по ключу. Это отлично подходит для маппинга полей различных систем. Например, в одной системе тип документа "Договор", а в другой "Contract". Либо одна система принимает буквенный код валюты "RUB", а другая числовой "643". Для того чтобы они понимали друг друга, необходимо переводить значения в понятные для этой системы, и для этого прекрасно подходят словари.

Я решил создать словари для каждой из систем:

SERVICE_PROVIDER_MAPPING = {
    "Договор": "Contract",
    "Доп. соглашение": "SupplementaryAgreement",
}


PROVIDER_SERVICE_MAPPING = {
    "Contract": "Договор",
    "SupplementaryAgreement": "Доп. соглашение",
}

Внешне это выглядит просто, и обратный словарь можно собрать при помощи copy-paste из первого словаря. Это хорошо когда мало значений, но вот дошло дело до кодов валют и их словаря в 160 записей. Сразу пришла в голову идея:

Был бы такой объект в python, в котором происходит маппинг не зависимо от передаваемого ключа. Передаешь RUB получаешь 643, передаешь 643 получаешь RUB

Я подумал об этом и сразу начал искать в интернете что-то подобное. К сожалению, ничего не нашел, но везде рекомендовали просто создать обратный словарь с помощью кода (как я сразу об этом не догадался):

{v: k for k, v in my_dict.items()}

И вот, после длительной работы, я представляю вашему вниманию мой класс SupperMapping. Этот класс позволяет осуществлять маппинг в обе стороны, независимо от того, какой ключ был передан.

class SupperMapping:
    """
    Этот класс реализует словарь, которое позволяет получать значения
    как по прямым, так и по обратным ключам.
    """

    def __init__(
            self,
            mapping: dict,
            default: str | int | None = None,
            default_key: str | int | None = None
    ):
        """
        Инициализирует экземпляр класса SupperMapping.

        :param mapping: словарь, которое нужно использовать
            для инициализации экземпляра класса SupperMapping.
        :param default: значение по умолчанию, которое будет возвращаться
            методом get, если указанный ключ не будет найден в словаре.
        :param default_key: значение ключа по умолчанию,
            который будет возвращаться значение методом get, если значение по ключу
            не будет найдено.
        """
        self._check_default_params(default, default_key)
        self.default = default
        self.default_key = default_key
        self._mapping = mapping
        self._reverse_mapping = {
            v: k for k, v in self._mapping.items()
        }

    def __contains__(self, key: str | int) -> bool:
        """
        Возвращает True, если указанный ключ присутствует в словаре
        или в обратном словаре, и False в противном случае.

        :param key: ключ, который нужно проверить на присутствие в словаре.
        :return: логическое значение, указывающее,
            присутствует ли указанный ключ в словаре.
        """
        for target_dict in (self._mapping, self._reverse_mapping):
            _, in_dict = self._key_in_dict(key, target_dict)
            if in_dict:
                return True
        return False

    def __getitem__(self, key: str | int) -> str | int:
        """
        Возвращает значение по указанному ключу из словаря
        или из обратного словаря.
        Если ключ не найден, генерирует исключение KeyError.

        :param key: ключ, по которому нужно получить значение.
        :return: значение, соответствующее указанному ключу.
        """
        for target_dict in (self._mapping, self._reverse_mapping):
            key, in_dict = self._key_in_dict(key, target_dict)
            if in_dict:
                return target_dict[key]
        raise KeyError(key)

    def get(
            self,
            key: str | int,
            default: str | int | None = None,
            default_key: str | int = None
    ) -> str | int | None:
        """
        Возвращает значение по указанному ключу из словаря
        или из обратного словаря.
        Если ключ не найден, возвращает значение по умолчанию,
        указанное в параметрах default или default_key.
        Если ни один из этих параметров не указан, возвращает None.

        :param key: ключ, по которому нужно получить значение.
        :param default: значение по умолчанию, которое будет возвращаться,
            если указанный ключ не будет найден в словаре.
        :param default_key: ключ по умолчанию для поиска значения из
            словаря которое будет возвращаться,
            если указанный ключ не будет найден в словаре.
        :return: значение, соответствующее указанному ключу,
            или значение по умолчанию.
        """
        try:
            return self[key]
        except KeyError:
            pass
        self._check_default_params(default, default_key)

        if default_key:
            return self.get(default_key)
        if default:
            return default
        if self.default_key:
            return self.get(self.default_key)
        return self.default

    def _key_in_dict(
            self,
            key: str | int,
            target_dict: dict
    ) -> tuple[str | int, bool]:
        """
        Проверяет, присутствует ли указанный ключ в указанном словаре.

        :param key: ключ, который нужно проверить на присутствие в словаре.
        :param target_dict: словарь, в котором нужно проверить
            наличие указанного ключа.
        :return: кортеж, содержащий ключ и логическое значение,
            указывающее, присутствует ли ключ в словаре.
        """
        try:
            key = self._convert_key_type(key, target_dict)
        except ValueError:
            return key, False
        is_in_dict = key in self._mapping or key in self._reverse_mapping
        return key, is_in_dict

    @staticmethod
    def _convert_key_type(key: str | int, target_dict: dict) -> str | int:
        """
        Преобразует тип указанного ключа к типу ключей указанного словаря.
        Если преобразование невозможно, генерирует исключение ValueError.

        :param key: ключ, тип которого нужно преобразовать.
        :param target_dict: словарь, ключи которого используются
            для определения типа, к которому нужно преобразовать
            указанный ключ.
        :return: преобразованный ключ.
        """
        mapping_key_type = type(next(iter(target_dict.keys())))
        if not isinstance(key, mapping_key_type):
            try:
                key = mapping_key_type(key)
            except Exception as err:
                raise ValueError(f"Invalid key type: {err}")
        return key

    @staticmethod
    def _check_default_params(*args):
        """
        Проверяет, были ли указаны оба параметра default и default_reverse.
        Если оба параметра указаны, генерирует исключение ValueError.

        :param args: список параметров, которые нужно проверить
            на наличие вместе
        :return: None
        """
        if all(args):
            raise ValueError(
                "Cannot specify both "
                "default and default_reverse "
                "arguments together"
            )

Я постарался подробно описать методы и их предназначение.

Пример использования

mapping_dict = {
            1: 'one',
            2: 'two',
            3: 'three'
        }

digit_mapping = SupperMapping(mapping_dict)

# Проверка наличия ключа
assert 1 in digit_mapping
assert 'one' in digit_mapping
assert 4 not in digit_mapping
assert 'four' not in digit_mapping

# Получение значения по ключу
assert digit_mapping[1] == 'one'
assert digit_mapping['two'] == 2
assert digit_mapping['2'] == 'two'
assert digit_mapping.get('2') == 'two'
assert digit_mapping.get(4) == None

# Получение значения по умолчанию, если ключ не найден
assert digit_mapping.get(4, 'five') == 'five'
assert digit_mapping.get('four', 2) == 2
assert digit_mapping.get('four', default_key=2) == 'two'

Это начальный вариант, думаю потом прикрутить еще больше фишек. Буду рад замечаниям и советам. Если будет потребность в этом классе можно попробовать и библиотеку на PIP выложить)))

ссылка на репозиторий

Комментарии (14)


  1. nsaktaganov
    06.04.2024 11:58
    +2

    Может имеет смысл добавить режим, в котором проверяется наличие коллизии между ключами начального/прямого и обратного словаря? Например, можно выбрасывать исключение в таком случае. И добавить проверку, что все значения прямого словаря уникальные. И также выбрасывать исключение в случае обнаружения дубликатов.


    1. cement-hools Автор
      06.04.2024 11:58
      +1

      кстати да, спасибо, не подумал об этом. Хотя может список отдавать значений, чтобы потом выбрать можно было. но тогда не понятно будет что ожидать, или значение или список, проверки дополнительные со значение проводить. Есть еще над чем подумать


  1. 9982th
    06.04.2024 11:58

    Например, в одной системе тип документа "Договор", а в другой "Contract".

    одна система принимает буквенный код валюты "RUB", а другая числовой "643"

    А что вы будете делать, когда систем станет три?


    1. cement-hools Автор
      06.04.2024 11:58

      там получается у меня одна система, главный сервис, который управляет разными провайдерами. И если появися еще один провайдер, я просто сделаю для него класс маппинга. получится чтобы передать данные в провайдер, я буду для каждого конвертировать модель с участвием мапинга


  1. nickolaym
    06.04.2024 11:58
    +1

    160 элементов - это всё ещё "мало". Были бы сотни тысяч, можно было бы подумать об эффективном хранении и из-за этого - об эффективном доступе. А тут...

    Отдельный минус подхода - смешивание ключей туда-обратно. В языке со слабой типизацией можно накосячить с лёгкостью необычайной. Лучше соблюдать чистоту рук, однако!

    Но кстати, быстрое-и-грязное решение - это напихать ключи в один словарь

    def make_bimap(src):  # src - dict
        dst = {}
        dst.update(src)
        dst.update((v, k) for (k, v) in src.items())
        return dst

    Но при этом надо удостовериться, что нет конфликта ключей в обратном направлении.

    Ну и не забываем про наследие предков. Бимапы люди делали издревле, и быстрый поиск даёт, например, PyPy.bidict.


    1. cement-hools Автор
      06.04.2024 11:58

      я наверное не знал что это бимап называтся, того и не нашел нечего))). Но все ровно было интересно по изобретать


  1. dyadyaSerezha
    06.04.2024 11:58

    Так-то каждый может. А вот сделайте такую мапу, у которой спрашиваешь про новый договор: m.get("договор №18"), а она возвращает: "составлен, одобрен, подписан и уже завершён, деньги уже на вашем счету". Вот это я понимаю.


  1. danilovmy
    06.04.2024 11:58
    +1

    Скорее всего, что подход "би"-маппинга стоит решать на уровне получения объектов из БД. Это работает, если возможно контролировать БД всех систем.

    В остальных случаях идея может быть улучшена, если известна система "источник" и система "назначение", а они известны в текущем примере, поскольку в этом случае маппинг выполняется с помощью только одного словаря "sourse: destination" и исключено появление каких-либо ошибок по ключам, о которых писали предыдущие комментаторы.

    PS. Код не работает, если values вmapping: dict не hashable. А на __init__ это не проверяется.


    1. cement-hools Автор
      06.04.2024 11:58

      не создастся класс тогда, ошибка словаря зарайзиртся


      1. danilovmy
        06.04.2024 11:58

        Верно. Но этот Exception в коде никак не обрабатывается. Я это имел ввиду под "не работает".


  1. googoosik
    06.04.2024 11:58
    +1

    Был бы такой объект в python, в котором происходит маппинг не зависимо
    от передаваемого ключа. Передаешь RUB получаешь 643, передаешь 643
    получаешь RUB

    Он есть.
    from enum import Enum
    class Currencies(Enum):

    RUB = 840

    Currencies['RUB']
    Out[8]: <_Currencies.RUB: 840>

    Currencies(840)
    Out[10]: <_Currencies.RUB: 840>


    1. cement-hools Автор
      06.04.2024 11:58

      Прикольно, не знал что он так работает. А он значения отдаст тоже как хэш-таблица? Почитал исходники, не совсем понял как он работает. По сути то что мне и нужно, но если бы я знал, не придумал бы сам такой велосипед))). У Enum по разному надо вызывать метод чтобы получить значения (Currencies['RUB'], Currencies(840)) а меня однообразно (может это преимущество или недостаток)))

      assert digit_mapping['two'] == 2
      assert digit_mapping[2] == 'two'
      assert digit_mapping['2'] == 'two'


      1. googoosik
        06.04.2024 11:58

        Не уверен, что правильно понял Ваш вопрос, но хранит енам данные в дикте, да.

        from enum import Enum
        class Currencies(Enum):
            RUB = 840
        
        Currencies.__members__
        Out[6]: mappingproxy({'RUB': <Currencies.RUB: 840>})
        rub = Currencies.__members__['RUB']
        rub.value
        Out[8]: 840
        rub.name
        Out[9]: 'RUB'


  1. Nikolaev_Nikolay
    06.04.2024 11:58

    Скажите а как сделать так, чтобы f(a, b)=c где с линейная зависимость от некой функции в которую входят а и b!? То есть а и b подставляем в словарь и он выдает с который монотонно возрастает от а и b и линейный сам по себе!?