amocrm.codegen — документация amocrm-sdk 0.1.0

# Исходный код amocrm.codegen

    """Кодогенератор типизированных DTO для кастомных полей AmoCRM.

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

        from amocrm import OAuthConfig, DjangoTokenStorage
        from amocrm.codegen import generate_custom_fields_dto

        oauth = OAuthConfig(
            client_id="...",
            client_secret="...",
            redirect_uri="...",
            storage=DjangoTokenStorage(my_settings_obj),
        )
        generate_custom_fields_dto("mycompany", oauth, output_file="my_models.py")
    """

    from __future__ import annotations

    import keyword
    import re
    import sys

    from .auth import OAuthConfig
    from .client import AmoCRM
    from .models.custom_fields import CustomFieldDefinition

    _DEFAULT_ENTITIES = ["leads", "contacts", "companies"]

    _ENTITY_CLASS_MAP: dict[str, str] = {
        "leads": "Lead",
        "contacts": "Contact",
        "companies": "Company",
    }

    _ENTITY_SUBCLASS_MAP: dict[str, str] = {
        "leads": "MyLead",
        "contacts": "MyContact",
        "companies": "MyCompany",
    }

    # (return_type, method, bool_special)
    _TYPE_MAP: dict[str, tuple[str, str, bool]] = {
        "text": ("str | None", "get_cf_value", False),
        "textarea": ("str | None", "get_cf_value", False),
        "url": ("str | None", "get_cf_value", False),
        "phone": ("str | None", "get_cf_value", False),
        "email": ("str | None", "get_cf_value", False),
        "numeric": ("float | None", "get_cf_value", False),
        "monetary": ("float | None", "get_cf_value", False),
        "integer": ("int | None", "get_cf_value", False),
        "select": ("str | None", "get_cf_value", False),
        "radiobutton": ("str | None", "get_cf_value", False),
        "multiselect": ("list[str]", "get_cf_values", False),
        "checkbox": ("bool | None", "get_cf_value", True),
        "bool": ("bool | None", "get_cf_value", True),
        "date": ("int | None", "get_cf_value", False),
        "date_time": ("int | None", "get_cf_value", False),
        "birthday": ("int | None", "get_cf_value", False),
        "smart_address": ("dict[str, Any] | None", "get_cf_raw", False),
    }

    _SETTER_METHOD_MAP: dict[str, str] = {
        "get_cf_value": "set_cf_value",
        "get_cf_values": "set_cf_values",
        "get_cf_raw": "set_cf_raw",
    }

    # Поля датаклассов Lead/Contact/Company — конфликты по имени нужно разрешать
    _ENTITY_FIELDS: dict[str, frozenset[str]] = {
        "leads": frozenset(
            {
                "id",
                "name",
                "price",
                "status_id",
                "pipeline_id",
                "responsible_user_id",
                "group_id",
                "created_by",
                "updated_by",
                "created_at",
                "updated_at",
                "closed_at",
                "closest_task_at",
                "is_deleted",
                "loss_reason_id",
                "score",
                "account_id",
                "labor_cost",
                "tags",
                "custom_fields_values",
            }
        ),
        "contacts": frozenset(
            {
                "id",
                "name",
                "first_name",
                "last_name",
                "responsible_user_id",
                "group_id",
                "created_by",
                "updated_by",
                "created_at",
                "updated_at",
                "closest_task_at",
                "is_deleted",
                "account_id",
                "tags",
                "custom_fields_values",
            }
        ),
        "companies": frozenset(
            {
                "id",
                "name",
                "responsible_user_id",
                "group_id",
                "created_by",
                "updated_by",
                "created_at",
                "updated_at",
                "closest_task_at",
                "is_deleted",
                "account_id",
                "tags",
                "custom_fields_values",
            }
        ),
    }

    _TRANSLIT_MAP: dict[str, str] = {
        "а": "a",
        "б": "b",
        "в": "v",
        "г": "g",
        "д": "d",
        "е": "e",
        "ё": "yo",
        "ж": "zh",
        "з": "z",
        "и": "i",
        "й": "j",
        "к": "k",
        "л": "l",
        "м": "m",
        "н": "n",
        "о": "o",
        "п": "p",
        "р": "r",
        "с": "s",
        "т": "t",
        "у": "u",
        "ф": "f",
        "х": "kh",
        "ц": "ts",
        "ч": "ch",
        "ш": "sh",
        "щ": "shch",
        "ъ": "",
        "ы": "y",
        "ь": "",
        "э": "e",
        "ю": "yu",
        "я": "ya",
    }

    def _transliterate(s: str) -> str:
        return "".join(_TRANSLIT_MAP.get(c, c) for c in s.lower())

    def _to_snake_case(name: str) -> str:
        """Преобразовать название поля в snake_case."""
        name = _transliterate(name)
        result = re.sub(r"[\s\-/\\]+", "_", name.strip())
        result = re.sub(r"[^\w]", "_", result)
        result = re.sub(r"_+", "_", result)
        result = result.strip("_").lower()
        return result or "field"

    def _safe_prop_name(raw_name: str, entity: str, used: set[str]) -> str:
        """Вернуть безопасное имя property, избегая конфликтов."""
        snake = _to_snake_case(raw_name)
        reserved = _ENTITY_FIELDS.get(entity, frozenset())
        candidate = snake
        if keyword.iskeyword(candidate) or candidate in reserved or candidate in used:
            candidate = candidate + "_cf"
        # На случай если и с суффиксом конфликт
        while candidate in used:
            candidate = candidate + "_cf"
        used.add(candidate)
        return candidate

    def _render_property(
        prop_name: str,
        field: CustomFieldDefinition,
        return_type: str,
        method: str,
        bool_special: bool,
    ) -> str:
        """Сгенерировать код property для одного поля."""
        lines = [
            "    @property",
            f"    def {prop_name}(self) -> {return_type}:",
            f'        """{field.name} ({field.type}, id={field.id})"""',
        ]
        if bool_special:
            lines += [
                f"        v = self.{method}({field.id})",
                "        return bool(v) if v is not None else None",
            ]
        else:
            lines += [
                f"        return self.{method}({field.id})",
            ]
        setter_method = _SETTER_METHOD_MAP[method]
        lines += [
            "",
            f"    @{prop_name}.setter",
            f"    def {prop_name}(self, value: {return_type}) -> None:",
            f"        self.{setter_method}({field.id}, value)",
        ]
        return "\n".join(lines)

    def fetch_custom_fields(
        client: AmoCRM,
        entities: list[str] | None = None,
    ) -> dict[str, list[CustomFieldDefinition]]:
        """Загрузить определения кастомных полей из AmoCRM для заданных сущностей.

        Args:
            client: Инициализированный клиент :class:`~amocrm.client.AmoCRM`.
            entities: Список типов сущностей. По умолчанию
                ``["leads", "contacts", "companies"]``.

        Returns:
            Словарь ``{entity: [CustomFieldDefinition, ...]}``.
        """
        if entities is None:
            entities = _DEFAULT_ENTITIES
        return {entity: client.custom_fields.list(entity) for entity in entities}

    def generate_custom_models(
        fields_by_entity: dict[str, list[CustomFieldDefinition]],
        base_module: str = "amocrm",
    ) -> str:
        """Сгенерировать Python-код типизированных подклассов DTO.

        Args:
            fields_by_entity: Словарь ``{entity: [CustomFieldDefinition, ...]}``,
                полученный из :func:`fetch_custom_fields`.
            base_module: Имя модуля, из которого импортируются базовые классы.
                По умолчанию ``"amocrm"``.

        Returns:
            Строка с Python-кодом, готовая к записи в файл.
        """
        # Определяем, какие базовые классы нужны
        needed_bases: list[str] = []
        for entity in fields_by_entity:
            base = _ENTITY_CLASS_MAP.get(entity)
            if base and base not in needed_bases:
                needed_bases.append(base)

        needs_any = any(
            _TYPE_MAP.get(f.type, ("Any", "get_cf_value", False))[0]
            in ("dict[str, Any] | None", "Any")
            for fields in fields_by_entity.values()
            for f in fields
        )

        header_lines = [
            "# Generated by amocrm-sdk codegen. DO NOT EDIT.",
            "from __future__ import annotations",
            "",
        ]
        if needs_any:
            header_lines.append("from typing import Any")
            header_lines.append("")
        header_lines.append(f"from {base_module} import {', '.join(needed_bases)}")
        header_lines.append("")
        header_lines.append("")

        class_blocks: list[str] = []
        for entity, fields in fields_by_entity.items():
            base_class = _ENTITY_CLASS_MAP.get(entity)
            if not base_class:
                continue
            subclass_name = _ENTITY_SUBCLASS_MAP.get(entity, f"My{base_class}")
            docstring_entity = {
                "leads": "сделок",
                "contacts": "контактов",
                "companies": "компаний",
            }.get(entity, entity)

            class_lines = [
                f"class {subclass_name}({base_class}):",
                f'    """Типизированные кастомные поля для {docstring_entity}."""',
                "",
            ]

            used_names: set[str] = set()
            properties: list[str] = []
            for field_def in fields:
                return_type, method, bool_special = _TYPE_MAP.get(
                    field_def.type, ("Any", "get_cf_value", False)
                )
                prop_name = _safe_prop_name(field_def.name, entity, used_names)
                prop_code = _render_property(
                    prop_name, field_def, return_type, method, bool_special
                )
                properties.append(prop_code)

            if properties:
                class_lines.append("\n\n".join(properties))
            else:
                class_lines.append("    pass")

            class_blocks.append("\n".join(class_lines))

        return "\n".join(header_lines) + "\n\n".join(class_blocks) + "\n"

    def generate_and_print(
        client: AmoCRM,
        entities: list[str] | None = None,
        base_module: str = "amocrm",
        output_file: str | None = None,
    ) -> None:
        """Получить кастомные поля и вывести/записать сгенерированный Python-код.

        Args:
            client: Инициализированный клиент :class:`~amocrm.client.AmoCRM`.
            entities: Список типов сущностей. По умолчанию
                ``["leads", "contacts", "companies"]``.
            base_module: Имя модуля для импорта базовых классов.
            output_file: Путь к файлу для записи. Если ``None`` — вывод в stdout.
        """
        fields_by_entity = fetch_custom_fields(client, entities)
        code = generate_custom_models(fields_by_entity, base_module)
        if output_file is None:
            sys.stdout.write(code)
        else:
            with open(output_file, "w", encoding="utf-8") as f:
                f.write(code)

    def generate_custom_fields_dto(
        subdomain: str,
        oauth: OAuthConfig,
        entities: list[str] | None = None,
        base_module: str = "amocrm",
        output_file: str | None = None,
    ) -> None:
        """Подключиться к AmoCRM, получить кастомные поля и записать Python-код.

        Создаёт клиент, загружает определения кастомных полей и генерирует
        типизированные подклассы DTO для использования в проекте.

        Args:
            subdomain: Поддомен аккаунта AmoCRM (например, ``"mycompany"``).
            oauth: Конфигурация OAuth с реквизитами и хранилищем токенов.
            entities: Список сущностей для обработки. По умолчанию
                ``["leads", "contacts", "companies"]``.
            base_module: Имя модуля для импорта базовых классов.
                По умолчанию ``"amocrm"``.
            output_file: Путь к файлу для записи. Если ``None`` — вывод в stdout.

        Example::

            from amocrm import OAuthConfig, DjangoTokenStorage
            from amocrm.codegen import generate_custom_fields_dto

            oauth = OAuthConfig(
                client_id="...",
                client_secret="...",
                redirect_uri="...",
                storage=DjangoTokenStorage(my_settings_obj),
            )
            generate_custom_fields_dto("mycompany", oauth, output_file="my_models.py")
        """
        client = AmoCRM(subdomain, oauth)
        generate_and_print(client, entities, base_module, output_file)