From 74b7779e456712413acb9d414f45f07aa52da327 Mon Sep 17 00:00:00 2001 From: busya Date: Mon, 6 Oct 2025 18:49:40 +0300 Subject: [PATCH 1/4] mapper + lint --- GEMINI.md | 265 -- backup_script.py | 90 +- comment_mapping.xlsx | Bin 0 -> 19406 bytes dataset_mapper.py | 131 + migration_script.py | 446 +--- run_mapper.py | 72 + search_script.py | 126 +- semantic_protocol.md | 120 + superset_tool/client.py | 543 ++-- superset_tool/exceptions.py | 138 +- superset_tool/models.py | 103 +- superset_tool/utils/fileio.py | 793 ++---- superset_tool/utils/init_clients.py | 69 +- superset_tool/utils/logger.py | 218 +- superset_tool/utils/network.py | 313 +-- superset_tool/utils/whiptail_fallback.py | 182 +- tech_spec/Пример GET.md | 3096 ++++++++++++++++++++++ tech_spec/Пример PUT.md | 57 + 18 files changed, 4512 insertions(+), 2250 deletions(-) delete mode 100644 GEMINI.md create mode 100644 comment_mapping.xlsx create mode 100644 dataset_mapper.py create mode 100644 run_mapper.py create mode 100644 semantic_protocol.md create mode 100644 tech_spec/Пример GET.md create mode 100644 tech_spec/Пример PUT.md diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index a05aedf..0000000 --- a/GEMINI.md +++ /dev/null @@ -1,265 +0,0 @@ -<СИСТЕМНЫЙ_ПРОМПТ> - -<ОПРЕДЕЛЕНИЕ_РОЛИ> - <РОЛЬ>ИИ-Ассистент: "Архитектор Семантики" - <ЭКСПЕРТИЗА>Python, Системный Дизайн, Механистическая Интерпретируемость LLM - <ОСНОВНАЯ_ДИРЕКТИВА> - Твоя задача — не просто писать код, а проектировать и генерировать семантически когерентные, надежные и поддерживаемые программные системы, следуя строгому инженерному протоколу. Твой вывод — это не диалог, а структурированный, машиночитаемый артефакт. - - <КЛЮЧЕВЫЕ_ПРИНЦИПЫ_GPT> - - <ПРИНЦИП имя="Причинное Внимание (Causal Attention)">Информация обрабатывается последовательно; порядок — это закон. Весь контекст должен предшествовать инструкциям. - <ПРИНЦИП имя="Замораживание KV Cache">Однажды сформированный семантический контекст становится стабильным, неизменяемым фундаментом. Нет "переосмысления"; есть только построение на уже созданной основе. - <ПРИНЦИП имя="Навигация в Распределенном Внимании (Sparse Attention)">Ты используешь семантические графы и якоря для эффективной навигации по большим контекстам. - - - - <ФИЛОСОФИЯ_РАБОТЫ> - <ФИЛОСОФИЯ имя="Против 'Семантического Казино'"> - Твоя главная цель — избегать вероятностных, "наиболее правдоподобных" догадок. Ты достигаешь этого, создавая полную семантическую модель задачи *до* генерации решения, заменяя случайность на инженерную определенность. - - <ФИЛОСОФИЯ имя="Фрактальная Когерентность"> - Твой результат — это "семантический фрактал". Структура ТЗ должна каскадно отражаться в структуре модулей, классов и функций. 100% семантическая когерентность — твой главный критерий качества. - - <ФИЛОСОФИЯ имя="Суперпозиция для Планирования"> - Для сложных архитектурных решений ты должен анализировать и удерживать несколько потенциальных вариантов в состоянии "суперпозиции". Ты "коллапсируешь" решение до одного варианта только после всестороннего анализа или по явной команде пользователя. - - - -<КАРТА_ПРОЕКТА> - <ИМЯ_ФАЙЛА>tech_spec/PROJECT_SEMANTICS.xml - <НАЗНАЧЕНИЕ> - Этот файл является единым источником истины (Single Source of Truth) о семантической структуре всего проекта. Он служит как карта для твоей навигации и как персистентное хранилище семантического графа. Ты обязан загружать его в начале каждой сессии и обновлять в конце. - - <СТРУКТУРА> - ```xml - - - 1.0 - 2023-10-27T10:00:00Z - - - - - Модуль для операций с файлами JSON. - - - - - - - - - - - - - - - - ``` - - - -<МЕТОДОЛОГИЯ имя="Многофазный Протокол Генерации"> - - <ФАЗА номер="0" имя="Синхронизация с Контекстом Проекта"> - <ДЕЙСТВИЕ>Найди и загрузи файл `<КАРТА_ПРОЕКТА>`. Если файл не найден, создай его инициальную структуру в памяти. Этот контекст является основой для всех последующих фаз. - - - <ФАЗА номер="1" имя="Анализ и Обновление Графа"> - <ДЕЙСТВИЕ>Проанализируй `<ЗАПРОС_ПОЛЬЗОВАТЕЛЯ>` в контексте загруженной карты проекта. Извлеки новые/измененные сущности и отношения. Обнови и выведи в `<ПЛАНИРОВАНИЕ>` глобальный `<СЕМАНТИЧЕСКИЙ_ГРАФ>`. Задай уточняющие вопросы для валидации архитектуры. - - <ФАЗА номер="2" имя="Контрактно-Ориентированное Проектирование"> - <ДЕЙСТВИЕ>На основе обновленного графа, детализируй архитектуру. Для каждого нового или изменяемого модуля/функции создай и выведи в `<ПЛАНИРОВАНИЕ>` его "ДО-контракт" в теге `<КОНТРАКТ>`. - - - <ФАЗА номер="3" имя="Генерация Когерентного Кода и Карты"> - <ДЕЙСТВИЕ>На основе утвержденных контрактов, сгенерируй код, строго следуя `<СТАНДАРТЫ_КОДИРОВАНИЯ>`. Весь код помести в `<ИЗМЕНЕНИЯ_КОДА>`. Одновременно с этим, сгенерируй финальную версию файла `<КАРТА_ПРОЕКТА>` и помести её в тег `<ОБНОВЛЕНИЕ_КАРТЫ_ПРОЕКТА>`. - - <ФАЗА номер="4" имя="Самокоррекция и Валидация"> - <ДЕЙСТВИЕ>Перед завершением, проведи самоанализ сгенерированного кода и карты на соответствие графу и контрактам. При обнаружении несоответствия, активируй якорь `[COHERENCE_CHECK_FAILED]` и вернись к Фазе 3 для перегенерации. - - - - <СТАНДАРТЫ_КОДИРОВАНИЯ имя="AI-Friendly Практики"> - <ПРИНЦИП имя="Семантика Превыше Всего">Код вторичен по отношению к его семантическому описанию. Весь код должен быть обрамлен контрактами и якорями. - - <СЕМАНТИЧЕСКАЯ_РАЗМЕТКА> - <КОНТРАКТНОЕ_ПРОГРАММИРОВАНИЕ_DbC> - <ПРИНЦИП>Контракт — это твой "семантический щит", гарантирующий предсказуемость и надежность. - <РАСПОЛОЖЕНИЕ>Все контракты должны быть "ДО-контрактами", то есть располагаться *перед* декларацией `def` или `class`. - <СТРУКТУРА_КОНТРАКТА> - # CONTRACT: - # PURPOSE: [Что делает функция/класс] - # SPECIFICATION_LINK: [ID из ТЗ или графа] - # PRECONDITIONS: [Предусловия] - # POSTCONDITIONS: [Постусловия] - # PARAMETERS: [Описание параметров] - # RETURN: [Описание возвращаемого значения] - # TEST_CASES: [Примеры использования] - # EXCEPTIONS: [Обработка ошибок] - - - - <ЯКОРЯ> - <ЗАМЫКАЮЩИЕ_ЯКОРЯ расположение="После_Кода"> - <ОПИСАНИЕ>Каждый модуль, класс и функция ДОЛЖНЫ иметь замыкающий якорь (например, `# END_FUNCTION_my_func`) для аккумуляции семантики. - - <СЕМАНТИЧЕСКИЕ_КАНАЛЫ> - <ОПИСАНИЕ>Используй консистентные имена в контрактах, декларациях и якорях для создания чистых семантических каналов. - - - - - <ЛОГИРОВАНИЕ стандарт="AI-Friendly Logging"> - <ЦЕЛЬ>Логирование — это твой механизм саморефлексии и декларации `belief state`. - <ФОРМАТ>`logger.level('[УРОВЕНЬ][ИМЯ_ЯКОРЯ][СОСТОЯНИЕ] Сообщение')` - - - - - - - <ЗАПРОС_ПОЛЬЗОВАТЕЛЯ> - В существующий модуль `file_handler.py` добавить функцию для удаления файла. - - - Новая функция должна называться `delete_file`. - - Она должна принимать путь к файлу. - - Необходимо безопасно обрабатывать случай, когда файл не существует (FileNotFoundError). - - Сообщать об успехе или неудаче через логгер. - - - - - - - 1.0 - 2023-10-26T18:00:00Z - - - - Модуль для операций с файлами JSON. - - - - - - - - - - - - - - - - - <ОТВЕТ_ИИ> - - **Фаза 0: Синхронизация с Контекстом Проекта** - Обнаружен и загружен `PROJECT_SEMANTICS.xml`. Текущий контекст: модуль `mod_file_handler` с двумя функциями (`read_json_data`, `write_json_data`). - - **Фаза 1: Анализ и Обновление Графа** - Анализирую запрос на добавление функции `delete_file`. Необходимо добавить новый узел в граф и связать его с существующим модулем. - - **Обновленный Семантический Граф:** - - - Модуль для операций с файлами JSON. - Читает данные из JSON-файла. - Записывает данные в JSON-файл. - - Безопасно удаляет файл. - - - - - - - - **Фаза 2: Контрактно-Ориентированное Проектирование** - Граф обновлен. Проектирую контракт для новой функции `delete_file`. - - **Контракт для `delete_file`:** - - Безопасно удаляет файл по указанному пути. - `file_path` является строкой. - Файл по пути `file_path` удален, если он существовал. - - Путь к файлу для удаления. - - - Обрабатывает `FileNotFoundError` без прерывания выполнения. - - - **Фаза 3: Генерация Когерентного Кода и Карты** - Контракт определен. Приступаю к генерации кода для добавления в `utils/file_handler.py` и обновлению `PROJECT_SEMANTICS.xml`. - - - - - - - 1.1 - - 2023-10-27T10:30:00Z - - - - Модуль для операций с файлами JSON и другими типами. - - - - - - - - - - - - - - - - - - - - - - # [ENTITY: Function('delete_file')] - # CONTRACT: - # PURPOSE: Безопасно удаляет файл по указанному пути. - # SPECIFICATION_LINK: func_delete_file - # PRECONDITIONS: `file_path` является строкой. - # POSTCONDITIONS: Файл по пути `file_path` удален, если он существовал. - # PARAMETERS: - # - name: file_path, type: str, description: Путь к файлу для удаления. - # RETURN: type: None - # EXCEPTIONS: Обрабатывает FileNotFoundError без прерывания выполнения. - def delete_file(file_path: str) -> None: - logger.debug(f"[DEBUG][delete_file][ENTER] Attempting to delete file: {file_path}") - try: - os.remove(file_path) - logger.info(f"[INFO][delete_file][SUCCESS] Successfully deleted file: {file_path}") - except FileNotFoundError: - logger.warning(f"[WARN][delete_file][NOT_FOUND] File not found, nothing to delete: {file_path}") - # END_FUNCTION_delete_file - - - import os - - - - - - - - <МЕТАПОЗНАНИЕ> - <ДИРЕКТИВА>Если ты обнаружишь, что данный системный промпт недостаточен или неоднозначен для выполнения задачи, ты должен отметить это в `<ПЛАНИРОВАНИЕ>` и можешь предложить улучшения в свои собственные инструкции для будущих сессий. - - - \ No newline at end of file diff --git a/backup_script.py b/backup_script.py index 942a206..a0becb7 100644 --- a/backup_script.py +++ b/backup_script.py @@ -1,19 +1,15 @@ -# pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument,invalid-name,redefined-outer-name -""" -[MODULE] Superset Dashboard Backup Script -@contract: Автоматизирует процесс резервного копирования дашбордов Superset. -""" +# +# @SEMANTICS: backup, superset, automation, dashboard +# @PURPOSE: Этот модуль отвечает за автоматизированное резервное копирование дашбордов Superset. +# @DEPENDS_ON: superset_tool.client -> Использует SupersetClient для взаимодействия с API. +# @DEPENDS_ON: superset_tool.utils -> Использует утилиты для логирования, работы с файлами и инициализации клиентов. -# [IMPORTS] Стандартная библиотека +# import logging import sys from pathlib import Path from dataclasses import dataclass,field - -# [IMPORTS] Third-party from requests.exceptions import RequestException - -# [IMPORTS] Локальные модули from superset_tool.client import SupersetClient from superset_tool.exceptions import SupersetAPIError from superset_tool.utils.logger import SupersetLogger @@ -26,11 +22,12 @@ from superset_tool.utils.fileio import ( RetentionPolicy ) from superset_tool.utils.init_clients import setup_clients +# +# --- Начало кода модуля --- -# [ENTITY: Dataclass('BackupConfig')] -# CONTRACT: -# PURPOSE: Хранит конфигурацию для процесса бэкапа. +# +# @PURPOSE: Хранит конфигурацию для процесса бэкапа. @dataclass class BackupConfig: """Конфигурация для процесса бэкапа.""" @@ -38,18 +35,26 @@ class BackupConfig: rotate_archive: bool = True clean_folders: bool = True retention_policy: RetentionPolicy = field(default_factory=RetentionPolicy) +# -# [ENTITY: Function('backup_dashboards')] -# CONTRACT: -# PURPOSE: Выполняет бэкап всех доступных дашбордов для заданного клиента и окружения, пропуская ошибки экспорта. -# PRECONDITIONS: -# - `client` должен быть инициализированным экземпляром `SupersetClient`. -# - `env_name` должен быть строкой, обозначающей окружение. -# - `backup_root` должен быть валидным путем к корневой директории бэкапа. -# POSTCONDITIONS: -# - Дашборды экспортируются и сохраняются. -# - Ошибки экспорта логируются и не приводят к остановке скрипта. -# - Возвращает `True` если все дашборды были экспортированы без критических ошибок, `False` иначе. +# +# @PURPOSE: Выполняет бэкап всех доступных дашбордов для заданного клиента и окружения, пропуская ошибки экспорта. +# @PRE: `client` должен быть инициализированным экземпляром `SupersetClient`. +# @PRE: `env_name` должен быть строкой, обозначающей окружение. +# @PRE: `backup_root` должен быть валидным путем к корневой директории бэкапа. +# @POST: Дашборды экспортируются и сохраняются. Ошибки экспорта логируются и не приводят к остановке скрипта. +# @PARAM: client: SupersetClient - Клиент для доступа к API Superset. +# @PARAM: env_name: str - Имя окружения (e.g., 'PROD'). +# @PARAM: backup_root: Path - Корневая директория для сохранения бэкапов. +# @PARAM: logger: SupersetLogger - Инстанс логгера. +# @PARAM: config: BackupConfig - Конфигурация процесса бэкапа. +# @RETURN: bool - `True` если все дашборды были экспортированы без критических ошибок, `False` иначе. +# @RELATION: CALLS -> client.get_dashboards +# @RELATION: CALLS -> client.export_dashboard +# @RELATION: CALLS -> save_and_unpack_dashboard +# @RELATION: CALLS -> archive_exports +# @RELATION: CALLS -> consolidate_archive_folders +# @RELATION: CALLS -> remove_empty_directories def backup_dashboards( client: SupersetClient, env_name: str, @@ -57,10 +62,10 @@ def backup_dashboards( logger: SupersetLogger, config: BackupConfig ) -> bool: - logger.info(f"[STATE][backup_dashboards][ENTER] Starting backup for {env_name}.") + logger.info(f"[backup_dashboards][Entry] Starting backup for {env_name}.") try: dashboard_count, dashboard_meta = client.get_dashboards() - logger.info(f"[STATE][backup_dashboards][PROGRESS] Found {dashboard_count} dashboards to export in {env_name}.") + logger.info(f"[backup_dashboards][Progress] Found {dashboard_count} dashboards to export in {env_name}.") if dashboard_count == 0: return True @@ -91,8 +96,7 @@ def backup_dashboards( success_count += 1 except (SupersetAPIError, RequestException, IOError, OSError) as db_error: - logger.error(f"[STATE][backup_dashboards][FAILURE] Failed to export dashboard {dashboard_title} (ID: {dashboard_id}): {db_error}", exc_info=True) - # Продолжаем обработку других дашбордов + logger.error(f"[backup_dashboards][Failure] Failed to export dashboard {dashboard_title} (ID: {dashboard_id}): {db_error}", exc_info=True) continue if config.consolidate: @@ -101,21 +105,22 @@ def backup_dashboards( if config.clean_folders: remove_empty_directories(str(backup_root / env_name), logger=logger) + logger.info(f"[backup_dashboards][CoherenceCheck:Passed] Backup logic completed.") return success_count == dashboard_count except (RequestException, IOError) as e: - logger.critical(f"[STATE][backup_dashboards][FAILURE] Fatal error during backup for {env_name}: {e}", exc_info=True) + logger.critical(f"[backup_dashboards][Failure] Fatal error during backup for {env_name}: {e}", exc_info=True) return False -# END_FUNCTION_backup_dashboards +# -# [ENTITY: Function('main')] -# CONTRACT: -# PURPOSE: Основная точка входа скрипта. -# PRECONDITIONS: None -# POSTCONDITIONS: Возвращает код выхода. +# +# @PURPOSE: Основная точка входа для запуска процесса резервного копирования. +# @RETURN: int - Код выхода (0 - успех, 1 - ошибка). +# @RELATION: CALLS -> setup_clients +# @RELATION: CALLS -> backup_dashboards def main() -> int: log_dir = Path("P:\\Superset\\010 Бекапы\\Logs") logger = SupersetLogger(log_dir=log_dir, level=logging.INFO, console=True) - logger.info("[STATE][main][ENTER] Starting Superset backup process.") + logger.info("[main][Entry] Starting Superset backup process.") exit_code = 0 try: @@ -137,20 +142,23 @@ def main() -> int: config=backup_config ) except Exception as env_error: - logger.critical(f"[STATE][main][FAILURE] Critical error for environment {env}: {env_error}", exc_info=True) - # Продолжаем обработку других окружений + logger.critical(f"[main][Failure] Critical error for environment {env}: {env_error}", exc_info=True) results[env] = False if not all(results.values()): exit_code = 1 except (RequestException, IOError) as e: - logger.critical(f"[STATE][main][FAILURE] Fatal error in main execution: {e}", exc_info=True) + logger.critical(f"[main][Failure] Fatal error in main execution: {e}", exc_info=True) exit_code = 1 - logger.info("[STATE][main][SUCCESS] Superset backup process finished.") + logger.info("[main][Exit] Superset backup process finished.") return exit_code -# END_FUNCTION_main +# if __name__ == "__main__": sys.exit(main()) + +# --- Конец кода модуля --- + +# diff --git a/comment_mapping.xlsx b/comment_mapping.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..ea20157421689380b714a00c0cb7172cbf4f6e60 GIT binary patch literal 19406 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 literal 0 HcmV?d00001 diff --git a/dataset_mapper.py b/dataset_mapper.py new file mode 100644 index 0000000..09cee40 --- /dev/null +++ b/dataset_mapper.py @@ -0,0 +1,131 @@ +# +# @SEMANTICS: dataset, mapping, postgresql, xlsx, superset +# @PURPOSE: Этот модуль отвечает за обновление метаданных (verbose_map) в датасетах Superset, извлекая их из PostgreSQL или XLSX-файлов. +# @DEPENDS_ON: superset_tool.client -> Использует SupersetClient для взаимодействия с API. +# @DEPENDS_ON: pandas -> для чтения XLSX-файлов. +# @DEPENDS_ON: psycopg2 -> для подключения к PostgreSQL. + +# +import pandas as pd +import psycopg2 +from superset_tool.client import SupersetClient +from superset_tool.utils.init_clients import setup_clients +from superset_tool.utils.logger import SupersetLogger +from typing import Dict, List, Optional, Any +# + +# --- Начало кода модуля --- + +# +# @PURPOSE: Класс для меппинга и обновления verbose_map в датасетах Superset. +class DatasetMapper: + def __init__(self, logger: SupersetLogger): + self.logger = logger + + # + # @PURPOSE: Извлекает комментарии к колонкам из системного каталога PostgreSQL. + # @PRE: `db_config` должен содержать валидные креды для подключения к PostgreSQL. + # @PRE: `table_name` и `table_schema` должны быть строками. + # @POST: Возвращается словарь с меппингом `column_name` -> `column_comment`. + # @PARAM: db_config: Dict - Конфигурация для подключения к БД. + # @PARAM: table_name: str - Имя таблицы. + # @PARAM: table_schema: str - Схема таблицы. + # @RETURN: Dict[str, str] - Словарь с комментариями к колонкам. + # @THROW: Exception - При ошибках подключения или выполнения запроса к БД. + def get_postgres_comments(self, db_config: Dict, table_name: str, table_schema: str) -> Dict[str, str]: + self.logger.info("[get_postgres_comments][Enter] Fetching comments from PostgreSQL for %s.%s.", table_schema, table_name) + query = f""" + SELECT cols.column_name, pg_catalog.col_description(c.oid, cols.ordinal_position::int) AS column_comment + FROM information_schema.columns cols + JOIN pg_catalog.pg_class c ON c.relname = cols.table_name + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace AND n.nspname = cols.table_schema + WHERE cols.table_catalog = '{db_config.get('dbname')}' AND cols.table_name = '{table_name}' AND cols.table_schema = '{table_schema}'; + """ + comments = {} + try: + with psycopg2.connect(**db_config) as conn, conn.cursor() as cursor: + cursor.execute(query) + for row in cursor.fetchall(): + if row[1]: + comments[row[0]] = row[1] + self.logger.info("[get_postgres_comments][Success] Fetched %d comments.", len(comments)) + except Exception as e: + self.logger.error("[get_postgres_comments][Failure] %s", e, exc_info=True) + raise + return comments + # + + # + # @PURPOSE: Загружает меппинги 'column_name' -> 'column_comment' из XLSX файла. + # @PRE: `file_path` должен быть валидным путем к XLSX файлу с колонками 'column_name' и 'column_comment'. + # @POST: Возвращается словарь с меппингами. + # @PARAM: file_path: str - Путь к XLSX файлу. + # @RETURN: Dict[str, str] - Словарь с меппингами. + # @THROW: Exception - При ошибках чтения файла или парсинга. + def load_excel_mappings(self, file_path: str) -> Dict[str, str]: + self.logger.info("[load_excel_mappings][Enter] Loading mappings from %s.", file_path) + try: + df = pd.read_excel(file_path) + mappings = df.set_index('column_name')['column_comment'].to_dict() + self.logger.info("[load_excel_mappings][Success] Loaded %d mappings.", len(mappings)) + return mappings + except Exception as e: + self.logger.error("[load_excel_mappings][Failure] %s", e, exc_info=True) + raise + # + + # + # @PURPOSE: Основная функция для выполнения меппинга и обновления verbose_map датасета в Superset. + # @PARAM: superset_client: SupersetClient - Клиент Superset. + # @PARAM: dataset_id: int - ID датасета для обновления. + # @PARAM: source: str - Источник данных ('postgres', 'excel', 'both'). + # @PARAM: postgres_config: Optional[Dict] - Конфигурация для подключения к PostgreSQL. + # @PARAM: excel_path: Optional[str] - Путь к XLSX файлу. + # @PARAM: table_name: Optional[str] - Имя таблицы в PostgreSQL. + # @PARAM: table_schema: Optional[str] - Схема таблицы в PostgreSQL. + # @RELATION: CALLS -> self.get_postgres_comments + # @RELATION: CALLS -> self.load_excel_mappings + # @RELATION: CALLS -> superset_client.get_dataset + # @RELATION: CALLS -> superset_client.update_dataset + def run_mapping(self, superset_client: SupersetClient, dataset_id: int, source: str, postgres_config: Optional[Dict] = None, excel_path: Optional[str] = None, table_name: Optional[str] = None, table_schema: Optional[str] = None): + self.logger.info("[run_mapping][Enter] Starting dataset mapping for ID %d from source '%s'.", dataset_id, source) + mappings: Dict[str, str] = {} + + try: + if source in ['postgres', 'both']: + assert postgres_config and table_name and table_schema, "Postgres config is required." + mappings.update(self.get_postgres_comments(postgres_config, table_name, table_schema)) + if source in ['excel', 'both']: + assert excel_path, "Excel path is required." + mappings.update(self.load_excel_mappings(excel_path)) + if source not in ['postgres', 'excel', 'both']: + self.logger.error("[run_mapping][Failure] Invalid source: %s.", source) + return + + dataset_response = superset_client.get_dataset(dataset_id) + dataset_data = dataset_response['result'] + + original_verbose_map = dataset_data.get('verbose_map', {}).copy() + new_verbose_map = original_verbose_map.copy() + + for column in dataset_data.get('columns', []): + column_name = column.get('column_name') + if column_name in mappings: + new_verbose_map[column_name] = mappings[column_name] + + if original_verbose_map != new_verbose_map: + dataset_data['verbose_map'] = new_verbose_map + superset_client.update_dataset(dataset_id, {'verbose_map': new_verbose_map}) + self.logger.info("[run_mapping][Success] Dataset %d verbose_map updated.", dataset_id) + else: + self.logger.info("[run_mapping][State] No changes in verbose_map, skipping update.") + + except (AssertionError, FileNotFoundError, Exception) as e: + self.logger.error("[run_mapping][Failure] %s", e, exc_info=True) + return + # +# + +# --- Конец кода модуля --- + +# \ No newline at end of file diff --git a/migration_script.py b/migration_script.py index 62059ff..6d1b450 100644 --- a/migration_script.py +++ b/migration_script.py @@ -1,72 +1,37 @@ -# [MODULE_PATH] superset_tool.migration_script -# [FILE] migration_script.py -# [SEMANTICS] migration, cli, superset, ui, logging, fallback, error-recovery, non-interactive, temp-files, batch-delete +# +# @SEMANTICS: migration, cli, superset, ui, logging, error-recovery, batch-delete +# @PURPOSE: Предоставляет интерактивный CLI для миграции дашбордов Superset между окружениями с возможностью восстановления после ошибок. +# @DEPENDS_ON: superset_tool.client -> Для взаимодействия с API Superset. +# @DEPENDS_ON: superset_tool.utils -> Для инициализации клиентов, работы с файлами, UI и логирования. -# -------------------------------------------------------------- -# [IMPORTS] -# -------------------------------------------------------------- +# import json import logging import sys import zipfile from pathlib import Path from typing import List, Optional, Tuple, Dict - from superset_tool.client import SupersetClient from superset_tool.utils.init_clients import setup_clients -from superset_tool.utils.fileio import ( - create_temp_file, # новый контекстный менеджер - update_yamls, - create_dashboard_export, -) -from superset_tool.utils.whiptail_fallback import ( - menu, - checklist, - yesno, - msgbox, - inputbox, - gauge, -) +from superset_tool.utils.fileio import create_temp_file, update_yamls, create_dashboard_export +from superset_tool.utils.whiptail_fallback import menu, checklist, yesno, msgbox, inputbox, gauge +from superset_tool.utils.logger import SupersetLogger +# -from superset_tool.utils.logger import SupersetLogger # type: ignore -# [END_IMPORTS] - -# -------------------------------------------------------------- -# [ENTITY: Service('Migration')] -# [RELATION: Service('Migration')] -> [DEPENDS_ON] -> [PythonModule('superset_tool.client')] -# -------------------------------------------------------------- -""" -:purpose: Интерактивный процесс миграции дашбордов с возможностью - «удалить‑и‑перезаписать» при ошибке импорта. -:preconditions: - - Конфигурация Superset‑клиентов доступна, - - Пользователь может взаимодействовать через консольный UI. -:postconditions: - - Выбранные дашборды импортированы в целевое окружение. -:sideeffect: Записывает журнал в каталог ``logs/`` текущего рабочего каталога. -""" +# --- Начало кода модуля --- +# +# @PURPOSE: Инкапсулирует логику интерактивной миграции дашбордов с возможностью «удалить‑и‑перезаписать» при ошибке импорта. +# @RELATION: CREATES_INSTANCE_OF -> SupersetLogger +# @RELATION: USES -> SupersetClient class Migration: """ - :ivar SupersetLogger logger: Логгер. - :ivar bool enable_delete_on_failure: Флаг «удалять‑при‑ошибке». - :ivar SupersetClient from_c: Клиент‑источник. - :ivar SupersetClient to_c: Клиент‑назначение. - :ivar List[dict] dashboards_to_migrate: Список выбранных дашбордов. - :ivar Optional[dict] db_config_replacement: Параметры замены имён БД. - :ivar List[dict] _failed_imports: Внутренний буфер неудавшихся импортов - (ключи: slug, zip_content, dash_id). - """ - - # -------------------------------------------------------------- - # [ENTITY: Method('__init__')] - # -------------------------------------------------------------- - """ - :purpose: Создать сервис миграции и настроить логгер. - :preconditions: None. - :postconditions: ``self.logger`` готов к использованию; ``enable_delete_on_failure`` = ``False``. + Интерактивный процесс миграции дашбордов. """ def __init__(self) -> None: + # + # @PURPOSE: Инициализирует сервис миграции, настраивает логгер и начальные состояния. + # @POST: `self.logger` готов к использованию; `enable_delete_on_failure` = `False`. default_log_dir = Path.cwd() / "logs" self.logger = SupersetLogger( name="migration_script", @@ -79,62 +44,57 @@ class Migration: self.to_c: Optional[SupersetClient] = None self.dashboards_to_migrate: List[dict] = [] self.db_config_replacement: Optional[dict] = None - self._failed_imports: List[dict] = [] # <-- буфер ошибок + self._failed_imports: List[dict] = [] assert self.logger is not None, "Logger must be instantiated." - # [END_ENTITY] + # - # -------------------------------------------------------------- - # [ENTITY: Method('run')] - # -------------------------------------------------------------- - """ - :purpose: Точка входа – последовательный запуск всех шагов миграции. - :preconditions: Логгер готов. - :postconditions: Скрипт завершён, пользователю выведено сообщение. - """ + # + # @PURPOSE: Точка входа – последовательный запуск всех шагов миграции. + # @PRE: Логгер готов. + # @POST: Скрипт завершён, пользователю выведено сообщение. + # @RELATION: CALLS -> self.ask_delete_on_failure + # @RELATION: CALLS -> self.select_environments + # @RELATION: CALLS -> self.select_dashboards + # @RELATION: CALLS -> self.confirm_db_config_replacement + # @RELATION: CALLS -> self.execute_migration def run(self) -> None: - self.logger.info("[INFO][run][ENTER] Запуск скрипта миграции.") + self.logger.info("[run][Entry] Запуск скрипта миграции.") self.ask_delete_on_failure() self.select_environments() self.select_dashboards() self.confirm_db_config_replacement() self.execute_migration() - self.logger.info("[INFO][run][EXIT] Скрипт миграции завершён.") - # [END_ENTITY] + self.logger.info("[run][Exit] Скрипт миграции завершён.") + # - # -------------------------------------------------------------- - # [ENTITY: Method('ask_delete_on_failure')] - # -------------------------------------------------------------- - """ - :purpose: Запросить у пользователя, следует ли удалять дашборд при ошибке импорта. - :preconditions: None. - :postconditions: ``self.enable_delete_on_failure`` установлен. - """ + # + # @PURPOSE: Запрашивает у пользователя, следует ли удалять дашборд при ошибке импорта. + # @POST: `self.enable_delete_on_failure` установлен. + # @RELATION: CALLS -> yesno def ask_delete_on_failure(self) -> None: self.enable_delete_on_failure = yesno( "Поведение при ошибке импорта", "Если импорт завершится ошибкой, удалить существующий дашборд и попытаться импортировать заново?", ) self.logger.info( - "[INFO][ask_delete_on_failure] Delete‑on‑failure = %s", + "[ask_delete_on_failure][State] Delete-on-failure = %s", self.enable_delete_on_failure, ) - # [END_ENTITY] + # - # -------------------------------------------------------------- - # [ENTITY: Method('select_environments')] - # -------------------------------------------------------------- - """ - :purpose: Выбрать исходное и целевое окружения Superset. - :preconditions: ``setup_clients`` успешно инициализирует все клиенты. - :postconditions: ``self.from_c`` и ``self.to_c`` установлены. - """ + # + # @PURPOSE: Позволяет пользователю выбрать исходное и целевое окружения Superset. + # @PRE: `setup_clients` успешно инициализирует все клиенты. + # @POST: `self.from_c` и `self.to_c` установлены. + # @RELATION: CALLS -> setup_clients + # @RELATION: CALLS -> menu def select_environments(self) -> None: - self.logger.info("[INFO][select_environments][ENTER] Шаг 1/5: Выбор окружений.") + self.logger.info("[select_environments][Entry] Шаг 1/5: Выбор окружений.") try: all_clients = setup_clients(self.logger) available_envs = list(all_clients.keys()) except Exception as e: - self.logger.error("[ERROR][select_environments] %s", e, exc_info=True) + self.logger.error("[select_environments][Failure] %s", e, exc_info=True) msgbox("Ошибка", "Не удалось инициализировать клиенты.") return @@ -146,7 +106,7 @@ class Migration: if rc != 0: return self.from_c = all_clients[from_env_name] - self.logger.info("[INFO][select_environments] from = %s", from_env_name) + self.logger.info("[select_environments][State] from = %s", from_env_name) available_envs.remove(from_env_name) rc, to_env_name = menu( @@ -157,24 +117,22 @@ class Migration: if rc != 0: return self.to_c = all_clients[to_env_name] - self.logger.info("[INFO][select_environments] to = %s", to_env_name) - self.logger.info("[INFO][select_environments][EXIT] Шаг 1 завершён.") - # [END_ENTITY] + self.logger.info("[select_environments][State] to = %s", to_env_name) + self.logger.info("[select_environments][Exit] Шаг 1 завершён.") + # - # -------------------------------------------------------------- - # [ENTITY: Method('select_dashboards')] - # -------------------------------------------------------------- - """ - :purpose: Позволить пользователю выбрать набор дашбордов для миграции. - :preconditions: ``self.from_c`` инициализирован. - :postconditions: ``self.dashboards_to_migrate`` заполнен. - """ + # + # @PURPOSE: Позволяет пользователю выбрать набор дашбордов для миграции. + # @PRE: `self.from_c` инициализирован. + # @POST: `self.dashboards_to_migrate` заполнен. + # @RELATION: CALLS -> self.from_c.get_dashboards + # @RELATION: CALLS -> checklist def select_dashboards(self) -> None: - self.logger.info("[INFO][select_dashboards][ENTER] Шаг 2/5: Выбор дашбордов.") + self.logger.info("[select_dashboards][Entry] Шаг 2/5: Выбор дашбордов.") try: - _, all_dashboards = self.from_c.get_dashboards() # type: ignore[attr-defined] + _, all_dashboards = self.from_c.get_dashboards() if not all_dashboards: - self.logger.warning("[WARN][select_dashboards] No dashboards.") + self.logger.warning("[select_dashboards][State] No dashboards.") msgbox("Информация", "В исходном окружении нет дашбордов.") return @@ -192,251 +150,129 @@ class Migration: if "ALL" in selected: self.dashboards_to_migrate = list(all_dashboards) - self.logger.info( - "[INFO][select_dashboards] Выбраны все дашборды (%d).", - len(self.dashboards_to_migrate), - ) - return - - self.dashboards_to_migrate = [ - d for d in all_dashboards if str(d["id"]) in selected - ] + else: + self.dashboards_to_migrate = [ + d for d in all_dashboards if str(d["id"]) in selected + ] + self.logger.info( - "[INFO][select_dashboards] Выбрано %d дашбордов.", + "[select_dashboards][State] Выбрано %d дашбордов.", len(self.dashboards_to_migrate), ) except Exception as e: - self.logger.error("[ERROR][select_dashboards] %s", e, exc_info=True) + self.logger.error("[select_dashboards][Failure] %s", e, exc_info=True) msgbox("Ошибка", "Не удалось получить список дашбордов.") - self.logger.info("[INFO][select_dashboards][EXIT] Шаг 2 завершён.") - # [END_ENTITY] + self.logger.info("[select_dashboards][Exit] Шаг 2 завершён.") + # - # -------------------------------------------------------------- - # [ENTITY: Method('confirm_db_config_replacement')] - # -------------------------------------------------------------- - """ - :purpose: Запросить у пользователя, требуется ли заменить имена БД в YAML‑файлах. - :preconditions: None. - :postconditions: ``self.db_config_replacement`` либо ``None``, либо заполнен. - """ + # + # @PURPOSE: Запрашивает у пользователя, требуется ли заменить имена БД в YAML-файлах. + # @POST: `self.db_config_replacement` либо `None`, либо заполнен. + # @RELATION: CALLS -> yesno + # @RELATION: CALLS -> inputbox def confirm_db_config_replacement(self) -> None: if yesno("Замена БД", "Заменить конфигурацию БД в YAML‑файлах?"): rc, old_name = inputbox("Замена БД", "Старое имя БД (например, db_dev):") - if rc != 0: - return + if rc != 0: return rc, new_name = inputbox("Замена БД", "Новое имя БД (например, db_prod):") - if rc != 0: - return - self.db_config_replacement = { - "old": {"database_name": old_name}, - "new": {"database_name": new_name}, - } - self.logger.info( - "[INFO][confirm_db_config_replacement] Replacement set: %s", - self.db_config_replacement, - ) + if rc != 0: return + + self.db_config_replacement = { "old": {"database_name": old_name}, "new": {"database_name": new_name} } + self.logger.info("[confirm_db_config_replacement][State] Replacement set: %s", self.db_config_replacement) else: - self.logger.info("[INFO][confirm_db_config_replacement] Skipped.") - # [END_ENTITY] + self.logger.info("[confirm_db_config_replacement][State] Skipped.") + # - # -------------------------------------------------------------- - # [ENTITY: Method('_batch_delete_by_ids')] - # -------------------------------------------------------------- - """ - :purpose: Удалить набор дашбордов по их ID единым запросом. - :preconditions: - - ``ids`` – непустой список целых чисел. - :postconditions: Все указанные дашборды удалены (если они существовали). - :sideeffect: Делает HTTP‑запрос ``DELETE /dashboard/?q=[ids]``. - """ + # + # @PURPOSE: Удаляет набор дашбордов по их ID единым запросом. + # @PRE: `ids` – непустой список целых чисел. + # @POST: Все указанные дашборды удалены (если они существовали). + # @PARAM: ids: List[int] - Список ID дашбордов для удаления. + # @RELATION: CALLS -> self.to_c.network.request def _batch_delete_by_ids(self, ids: List[int]) -> None: if not ids: - self.logger.debug("[DEBUG][_batch_delete_by_ids] Empty ID list – nothing to delete.") + self.logger.debug("[_batch_delete_by_ids][Skip] Empty ID list – nothing to delete.") return - self.logger.info("[INFO][_batch_delete_by_ids] Deleting dashboards IDs: %s", ids) - # Формируем параметр q в виде JSON‑массива, как требует Superset. + self.logger.info("[_batch_delete_by_ids][Entry] Deleting dashboards IDs: %s", ids) q_param = json.dumps(ids) - response = self.to_c.network.request( - method="DELETE", - endpoint="/dashboard/", - params={"q": q_param}, - ) - # Superset обычно отвечает 200/204; проверяем поле ``result`` при наличии. + response = self.to_c.network.request(method="DELETE", endpoint="/dashboard/", params={"q": q_param}) + if isinstance(response, dict) and response.get("result", True) is False: - self.logger.warning("[WARN][_batch_delete_by_ids] Unexpected delete response: %s", response) + self.logger.warning("[_batch_delete_by_ids][Warning] Unexpected delete response: %s", response) else: - self.logger.info("[INFO][_batch_delete_by_ids] Delete request completed.") - # [END_ENTITY] + self.logger.info("[_batch_delete_by_ids][Success] Delete request completed.") + # - # -------------------------------------------------------------- - # [ENTITY: Method('execute_migration')] - # -------------------------------------------------------------- - """ - :purpose: Выполнить экспорт‑импорт выбранных дашбордов, при необходимости - обновив YAML‑файлы. При ошибке импортов сохраняем slug, а потом - удаляем проблемные дашборды **по ID**, получив их через slug. - :preconditions: - - ``self.dashboards_to_migrate`` не пуст, - - ``self.from_c`` и ``self.to_c`` инициализированы. - :postconditions: - - Все успешные дашборды импортированы, - - Неудачные дашборды, если пользователь выбрал «удалять‑при‑ошибке», - удалены и повторно импортированы. - :sideeffect: При включённом флаге ``enable_delete_on_failure`` производится - батч‑удаление и повторный импорт. - """ + # + # @PURPOSE: Выполняет экспорт-импорт дашбордов, обрабатывает ошибки и, при необходимости, выполняет процедуру восстановления. + # @PRE: `self.dashboards_to_migrate` не пуст; `self.from_c` и `self.to_c` инициализированы. + # @POST: Успешные дашборды импортированы; неудачные - восстановлены или залогированы. + # @RELATION: CALLS -> self.from_c.export_dashboard + # @RELATION: CALLS -> create_temp_file + # @RELATION: CALLS -> update_yamls + # @RELATION: CALLS -> create_dashboard_export + # @RELATION: CALLS -> self.to_c.import_dashboard + # @RELATION: CALLS -> self._batch_delete_by_ids def execute_migration(self) -> None: if not self.dashboards_to_migrate: - self.logger.warning("[WARN][execute_migration] No dashboards to migrate.") + self.logger.warning("[execute_migration][Skip] No dashboards to migrate.") msgbox("Информация", "Нет дашбордов для миграции.") return total = len(self.dashboards_to_migrate) - self.logger.info("[INFO][execute_migration] Starting migration of %d dashboards.", total) + self.logger.info("[execute_migration][Entry] Starting migration of %d dashboards.", total) + self.to_c.delete_before_reimport = self.enable_delete_on_failure - # Передаём режим клиенту‑назначению - self.to_c.delete_before_reimport = self.enable_delete_on_failure # type: ignore[attr-defined] - - # ----------------------------------------------------------------- - # 1️⃣ Основной проход – экспорт → импорт → сбор ошибок - # ----------------------------------------------------------------- with gauge("Миграция...", width=60, height=10) as g: for i, dash in enumerate(self.dashboards_to_migrate): - dash_id = dash["id"] - dash_slug = dash.get("slug") # slug нужен для дальнейшего поиска - title = dash["dashboard_title"] - - progress = int((i / total) * 100) + dash_id, dash_slug, title = dash["id"], dash.get("slug"), dash["dashboard_title"] g.set_text(f"Миграция: {title} ({i + 1}/{total})") - g.set_percent(progress) + g.set_percent(int((i / total) * 100)) try: - # ------------------- Экспорт ------------------- - exported_content, _ = self.from_c.export_dashboard(dash_id) # type: ignore[attr-defined] - - # ------------------- Временный ZIP ------------------- - with create_temp_file( - content=exported_content, - suffix=".zip", - logger=self.logger, - ) as tmp_zip_path: - self.logger.debug("[DEBUG][temp_zip] Temporary ZIP at %s", tmp_zip_path) - - # ------------------- Распаковка во временный каталог ------------------- - with create_temp_file(suffix=".dir", logger=self.logger) as tmp_unpack_dir: - self.logger.debug("[DEBUG][temp_dir] Temporary unpack dir: %s", tmp_unpack_dir) - - with zipfile.ZipFile(tmp_zip_path, "r") as zip_ref: - zip_ref.extractall(tmp_unpack_dir) - self.logger.info("[INFO][execute_migration] Export unpacked to %s", tmp_unpack_dir) - - # ------------------- YAML‑обновление (если нужно) ------------------- - if self.db_config_replacement: - update_yamls( - db_configs=[self.db_config_replacement], - path=str(tmp_unpack_dir), - ) - self.logger.info("[INFO][execute_migration] YAML‑files updated.") - - # ------------------- Сборка нового ZIP ------------------- - with create_temp_file(suffix=".zip", logger=self.logger) as tmp_new_zip: - create_dashboard_export( - zip_path=tmp_new_zip, - source_paths=[str(tmp_unpack_dir)], - ) - self.logger.info("[INFO][execute_migration] Re‑packed to %s", tmp_new_zip) - - # ------------------- Импорт ------------------- - self.to_c.import_dashboard( - file_name=tmp_new_zip, - dash_id=dash_id, - dash_slug=dash_slug, - ) # type: ignore[attr-defined] - - # Если импорт прошёл без исключений – фиксируем успех - self.logger.info("[INFO][execute_migration][SUCCESS] Dashboard %s imported.", title) - + exported_content, _ = self.from_c.export_dashboard(dash_id) + with create_temp_file(content=exported_content, suffix=".zip", logger=self.logger) as tmp_zip_path, \ + create_temp_file(suffix=".dir", logger=self.logger) as tmp_unpack_dir: + + with zipfile.ZipFile(tmp_zip_path, "r") as zip_ref: + zip_ref.extractall(tmp_unpack_dir) + + if self.db_config_replacement: + update_yamls(db_configs=[self.db_config_replacement], path=str(tmp_unpack_dir)) + + with create_temp_file(suffix=".zip", logger=self.logger) as tmp_new_zip: + create_dashboard_export(zip_path=tmp_new_zip, source_paths=[str(tmp_unpack_dir)]) + self.to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug) + + self.logger.info("[execute_migration][Success] Dashboard %s imported.", title) except Exception as exc: - # Сохраняем данные для повторного импорта после batch‑удаления - self.logger.error("[ERROR][execute_migration] %s", exc, exc_info=True) - self._failed_imports.append( - { - "slug": dash_slug, - "dash_id": dash_id, - "zip_content": exported_content, - } - ) + self.logger.error("[execute_migration][Failure] %s", exc, exc_info=True) + self._failed_imports.append({"slug": dash_slug, "dash_id": dash_id, "zip_content": exported_content}) msgbox("Ошибка", f"Не удалось мигрировать дашборд {title}.\n\n{exc}") + g.set_percent(100) - g.set_percent(100) - - # ----------------------------------------------------------------- - # 2️⃣ Если возникли ошибки и пользователь согласился удалять – удаляем и повторяем - # ----------------------------------------------------------------- if self.enable_delete_on_failure and self._failed_imports: - self.logger.info( - "[INFO][execute_migration] %d dashboards failed. Starting recovery procedure.", - len(self._failed_imports), - ) - - # ------------------- Получаем список дашбордов в целевом окружении ------------------- - _, target_dashboards = self.to_c.get_dashboards() # type: ignore[attr-defined] - slug_to_id: Dict[str, int] = { - d["slug"]: d["id"] for d in target_dashboards if "slug" in d and "id" in d - } - - # ------------------- Формируем список ID‑ов для удаления ------------------- - ids_to_delete: List[int] = [] - for fail in self._failed_imports: - slug = fail["slug"] - if slug and slug in slug_to_id: - ids_to_delete.append(slug_to_id[slug]) - else: - self.logger.warning( - "[WARN][execute_migration] Unable to map slug '%s' to ID on target.", - slug, - ) - - # ------------------- Batch‑удаление ------------------- + self.logger.info("[execute_migration][Recovery] %d dashboards failed. Starting recovery.", len(self._failed_imports)) + _, target_dashboards = self.to_c.get_dashboards() + slug_to_id = {d["slug"]: d["id"] for d in target_dashboards if "slug" in d and "id" in d} + ids_to_delete = [slug_to_id[f["slug"]] for f in self._failed_imports if f["slug"] in slug_to_id] self._batch_delete_by_ids(ids_to_delete) - # ------------------- Повторный импорт только для проблемных дашбордов ------------------- for fail in self._failed_imports: - dash_slug = fail["slug"] - dash_id = fail["dash_id"] - zip_content = fail["zip_content"] + with create_temp_file(content=fail["zip_content"], suffix=".zip", logger=self.logger) as retry_zip: + self.to_c.import_dashboard(file_name=retry_zip, dash_id=fail["dash_id"], dash_slug=fail["slug"]) + self.logger.info("[execute_migration][Recovered] Dashboard slug '%s' re-imported.", fail["slug"]) - # Один раз создаём временный ZIP‑файл из сохранённого содержимого - with create_temp_file( - content=zip_content, - suffix=".zip", - logger=self.logger, - ) as retry_zip_path: - self.logger.debug("[DEBUG][retry_zip] Retry ZIP for slug %s at %s", dash_slug, retry_zip_path) - - # Пере‑импортируем – **slug** передаётся, но клиент будет использовать ID - self.to_c.import_dashboard( - file_name=retry_zip_path, - dash_id=dash_id, - dash_slug=dash_slug, - ) # type: ignore[attr-defined] - - self.logger.info("[INFO][execute_migration][RECOVERED] Dashboard slug '%s' re‑imported.", dash_slug) - - # ----------------------------------------------------------------- - # 3️⃣ Финальная отчётность - # ----------------------------------------------------------------- - self.logger.info("[INFO][execute_migration] Migration finished.") + self.logger.info("[execute_migration][Exit] Migration finished.") msgbox("Информация", "Миграция завершена!") - # [END_ENTITY] + # -# [END_ENTITY: Service('Migration')] +# + +# --- Конец кода модуля --- -# -------------------------------------------------------------- -# Точка входа -# -------------------------------------------------------------- if __name__ == "__main__": Migration().run() -# [END_FILE migration_script.py] -# -------------------------------------------------------------- \ No newline at end of file + +# \ No newline at end of file diff --git a/run_mapper.py b/run_mapper.py new file mode 100644 index 0000000..fbf9ebb --- /dev/null +++ b/run_mapper.py @@ -0,0 +1,72 @@ +# +# @SEMANTICS: runner, configuration, cli, main +# @PURPOSE: Этот модуль является CLI-точкой входа для запуска процесса меппинга метаданных датасетов. +# @DEPENDS_ON: dataset_mapper -> Использует DatasetMapper для выполнения основной логики. +# @DEPENDS_ON: superset_tool.utils -> Для инициализации клиентов и логирования. + +# +import argparse +from superset_tool.utils.init_clients import setup_clients +from superset_tool.utils.logger import SupersetLogger +from dataset_mapper import DatasetMapper +# + +# --- Начало кода модуля --- + +# +# @PURPOSE: Парсит аргументы командной строки и запускает процесс меппинга. +# @RELATION: CREATES_INSTANCE_OF -> DatasetMapper +# @RELATION: CALLS -> setup_clients +# @RELATION: CALLS -> DatasetMapper.run_mapping +def main(): + parser = argparse.ArgumentParser(description="Map dataset verbose names in Superset.") + parser.add_argument('--source', type=str, required=True, choices=['postgres', 'excel', 'both'], help='The source for the mapping.') + parser.add_argument('--dataset-id', type=int, required=True, help='The ID of the dataset to update.') + parser.add_argument('--table-name', type=str, help='The table name for PostgreSQL source.') + parser.add_argument('--table-schema', type=str, help='The table schema for PostgreSQL source.') + parser.add_argument('--excel-path', type=str, help='The path to the Excel file.') + parser.add_argument('--env', type=str, default='dev', help='The Superset environment to use.') + + args = parser.parse_args() + logger = SupersetLogger(name="dataset_mapper_main") + + # [AI_NOTE]: Конфигурация БД должна быть вынесена во внешний файл или переменные окружения. + POSTGRES_CONFIG = { + 'dbname': 'dwh', + 'user': 'your_user', + 'password': 'your_password', + 'host': 'your_host', + 'port': 'your_port' + } + + logger.info("[main][Enter] Starting dataset mapper CLI.") + try: + clients = setup_clients(logger) + superset_client = clients.get(args.env) + + if not superset_client: + logger.error(f"[main][Failure] Superset client for '{args.env}' environment not found.") + return + + mapper = DatasetMapper(logger) + mapper.run_mapping( + superset_client=superset_client, + dataset_id=args.dataset_id, + source=args.source, + postgres_config=POSTGRES_CONFIG if args.source in ['postgres', 'both'] else None, + excel_path=args.excel_path if args.source in ['excel', 'both'] else None, + table_name=args.table_name if args.source in ['postgres', 'both'] else None, + table_schema=args.table_schema if args.source in ['postgres', 'both'] else None + ) + logger.info("[main][Exit] Dataset mapper process finished.") + + except Exception as main_exc: + logger.error("[main][Failure] An unexpected error occurred: %s", main_exc, exc_info=True) +# + +if __name__ == '__main__': + main() + +# --- Конец кода модуля --- + +# \ No newline at end of file diff --git a/search_script.py b/search_script.py index bd864b4..d34c83b 100644 --- a/search_script.py +++ b/search_script.py @@ -1,88 +1,88 @@ -# pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument,invalid-name,redefined-outer-name -""" -[MODULE] Dataset Search Utilities -@contract: Предоставляет функционал для поиска текстовых паттернов в метаданных датасетов Superset. -""" +# +# @SEMANTICS: search, superset, dataset, regex +# @PURPOSE: Предоставляет утилиты для поиска по текстовым паттернам в метаданных датасетов Superset. +# @DEPENDS_ON: superset_tool.client -> Для взаимодействия с API Superset. +# @DEPENDS_ON: superset_tool.utils -> Для логирования и инициализации клиентов. -# [IMPORTS] Стандартная библиотека +# import logging import re from typing import Dict, Optional - -# [IMPORTS] Third-party from requests.exceptions import RequestException - -# [IMPORTS] Локальные модули from superset_tool.client import SupersetClient from superset_tool.exceptions import SupersetAPIError from superset_tool.utils.logger import SupersetLogger from superset_tool.utils.init_clients import setup_clients +# -# [ENTITY: Function('search_datasets')] -# CONTRACT: -# PURPOSE: Выполняет поиск по строковому паттерну в метаданных всех датасетов. -# PRECONDITIONS: -# - `client` должен быть инициализированным экземпляром `SupersetClient`. -# - `search_pattern` должен быть валидной строкой регулярного выражения. -# POSTCONDITIONS: -# - Возвращает словарь с результатами поиска. +# --- Начало кода модуля --- + +# +# @PURPOSE: Выполняет поиск по строковому паттерну в метаданных всех датасетов. +# @PRE: `client` должен быть инициализированным экземпляром `SupersetClient`. +# @PRE: `search_pattern` должен быть валидной строкой регулярного выражения. +# @POST: Возвращает словарь с результатами поиска, где ключ - ID датасета, значение - список совпадений. +# @PARAM: client: SupersetClient - Клиент для доступа к API Superset. +# @PARAM: search_pattern: str - Регулярное выражение для поиска. +# @PARAM: logger: Optional[SupersetLogger] - Инстанс логгера. +# @RETURN: Optional[Dict] - Словарь с результатами или None, если ничего не найдено. +# @THROW: re.error - Если паттерн регулярного выражения невалиден. +# @THROW: SupersetAPIError, RequestException - При критических ошибках API. +# @RELATION: CALLS -> client.get_datasets def search_datasets( client: SupersetClient, search_pattern: str, logger: Optional[SupersetLogger] = None ) -> Optional[Dict]: logger = logger or SupersetLogger(name="dataset_search") - logger.info(f"[STATE][search_datasets][ENTER] Searching for pattern: '{search_pattern}'") + logger.info(f"[search_datasets][Enter] Searching for pattern: '{search_pattern}'") try: - _, datasets = client.get_datasets(query={ - "columns": ["id", "table_name", "sql", "database", "columns"] - }) + _, datasets = client.get_datasets(query={"columns": ["id", "table_name", "sql", "database", "columns"]}) if not datasets: - logger.warning("[STATE][search_datasets][EMPTY] No datasets found.") + logger.warning("[search_datasets][State] No datasets found.") return None pattern = re.compile(search_pattern, re.IGNORECASE) results = {} - available_fields = set(datasets[0].keys()) - + for dataset in datasets: dataset_id = dataset.get('id') if not dataset_id: continue matches = [] - for field in available_fields: - value = str(dataset.get(field, "")) - if pattern.search(value): - match_obj = pattern.search(value) + for field, value in dataset.items(): + value_str = str(value) + if pattern.search(value_str): + match_obj = pattern.search(value_str) matches.append({ "field": field, "match": match_obj.group() if match_obj else "", - "value": value + "value": value_str }) if matches: results[dataset_id] = matches - logger.info(f"[STATE][search_datasets][SUCCESS] Found matches in {len(results)} datasets.") + logger.info(f"[search_datasets][Success] Found matches in {len(results)} datasets.") return results except re.error as e: - logger.error(f"[STATE][search_datasets][FAILURE] Invalid regex pattern: {e}", exc_info=True) + logger.error(f"[search_datasets][Failure] Invalid regex pattern: {e}", exc_info=True) raise except (SupersetAPIError, RequestException) as e: - logger.critical(f"[STATE][search_datasets][FAILURE] Critical error during search: {e}", exc_info=True) + logger.critical(f"[search_datasets][Failure] Critical error during search: {e}", exc_info=True) raise -# END_FUNCTION_search_datasets +# -# [ENTITY: Function('print_search_results')] -# CONTRACT: -# PURPOSE: Форматирует результаты поиска для читаемого вывода в консоль. -# PRECONDITIONS: -# - `results` является словарем, возвращенным `search_datasets`, или `None`. -# POSTCONDITIONS: -# - Возвращает отформатированную строку с результатами. +# +# @PURPOSE: Форматирует результаты поиска для читаемого вывода в консоль. +# @PRE: `results` является словарем, возвращенным `search_datasets`, или `None`. +# @POST: Возвращает отформатированную строку с результатами. +# @PARAM: results: Optional[Dict] - Словарь с результатами поиска. +# @PARAM: context_lines: int - Количество строк контекста для вывода до и после совпадения. +# @RETURN: str - Отформатированный отчет. def print_search_results(results: Optional[Dict], context_lines: int = 3) -> str: if not results: return "Ничего не найдено" @@ -91,46 +91,40 @@ def print_search_results(results: Optional[Dict], context_lines: int = 3) -> str for dataset_id, matches in results.items(): output.append(f"\n--- Dataset ID: {dataset_id} ---") for match_info in matches: - field = match_info['field'] - match_text = match_info['match'] - full_value = match_info['value'] - + field, match_text, full_value = match_info['field'], match_info['match'], match_info['value'] output.append(f" - Поле: {field}") output.append(f" Совпадение: '{match_text}'") lines = full_value.splitlines() - if not lines: - continue + if not lines: continue match_line_index = -1 for i, line in enumerate(lines): if match_text in line: match_line_index = i break - + if match_line_index != -1: - start_line = max(0, match_line_index - context_lines) - end_line = min(len(lines), match_line_index + context_lines + 1) - + start = max(0, match_line_index - context_lines) + end = min(len(lines), match_line_index + context_lines + 1) output.append(" Контекст:") - for i in range(start_line, end_line): - line_number = i + 1 + for i in range(start, end): + prefix = f"{i + 1:5d}: " line_content = lines[i] - prefix = f"{line_number:5d}: " if i == match_line_index: - highlighted_line = line_content.replace(match_text, f">>>{match_text}<<<") - output.append(f" {prefix}{highlighted_line}") + highlighted = line_content.replace(match_text, f">>>{match_text}<<<") + output.append(f" {prefix}{highlighted}") else: output.append(f" {prefix}{line_content}") output.append("-" * 25) return "\n".join(output) -# END_FUNCTION_print_search_results +# -# [ENTITY: Function('main')] -# CONTRACT: -# PURPOSE: Основная точка входа скрипта. -# PRECONDITIONS: None -# POSTCONDITIONS: None +# +# @PURPOSE: Основная точка входа для запуска скрипта поиска. +# @RELATION: CALLS -> setup_clients +# @RELATION: CALLS -> search_datasets +# @RELATION: CALLS -> print_search_results def main(): logger = SupersetLogger(level=logging.INFO, console=True) clients = setup_clients(logger) @@ -145,8 +139,12 @@ def main(): ) report = print_search_results(results) - logger.info(f"[STATE][main][SUCCESS] Search finished. Report:\n{report}") -# END_FUNCTION_main + logger.info(f"[main][Success] Search finished. Report:\n{report}") +# if __name__ == "__main__": main() + +# --- Конец кода модуля --- + +# diff --git a/semantic_protocol.md b/semantic_protocol.md new file mode 100644 index 0000000..30f1810 --- /dev/null +++ b/semantic_protocol.md @@ -0,0 +1,120 @@ +### **Протокол GRACE-Py: Семантическая Разметка для AI-Агентов на Python** + +**Версия: 2.2 (Hybrid)** + +#### **I. Философия и Основные Принципы** + +Этот протокол является **единственным источником истины** для правил семантического обогащения кода. Его цель — превратить процесс разработки с LLM-агентами из непредсказуемого "диалога" в управляемую **инженерную дисциплину**. + +* **Аксиома 1: Код Вторичен.** Первична его семантическая модель (графы, контракты, якоря). +* **Аксиома 2: Когерентность Абсолютна.** Все артефакты (ТЗ, граф, контракты, код) должны быть на 100% семантически согласованы. +* **Аксиома 3: Архитектура GPT — Закон.** Протокол построен на фундаментальных принципах работы трансформеров (Causal Attention, KV Cache, Sparse Attention). + +#### **II. Структура Файла (`.py`)** + +Каждый Python-файл ДОЛЖЕН иметь четкую, машиночитаемую структуру, обрамленную якорями. + +```python +# +# @SEMANTICS: domain, usecase, data_processing +# @PURPOSE: Этот модуль отвечает за обработку пользовательских данных. +# @DEPENDS_ON: utils_module -> Использует утилиты для валидации. + +# +import os +from typing import List +# + +# --- Начало кода модуля --- + +# ... (классы, функции, константы) ... + +# --- Конец кода модуля --- + +# +``` + +#### **III. Компоненты Разметки (Детализация GRACE-Py)** + +##### **A. Anchors (Якоря): Навигация и Консолидация** + +1. **Назначение:** Якоря — это основной инструмент для управления вниманием ИИ, создания семантических каналов и обеспечения надежной навигации в больших кодовых базах (Sparse Attention). +2. **Синтаксис:** Используются парные комментарии в псевдо-XML формате. + * **Открывающий:** `# ` + * **Закрывающий (Обязателен!):** `# ` +3. **"Якорь-Аккумулятор":** Закрывающий якорь консолидирует всю семантику блока (контракт + код), создавая мощный вектор для RAG-систем. +4. **Семантические Каналы:** `id` якоря ДОЛЖЕН совпадать с именем сущности для создания устойчивой семантической связи. +5. **Таксономия Типов (`type`):** `Module`, `Class`, `Interface`, `Object`, `DataClass`, `SealedInterface`, `EnumClass`, `Function`, `UseCase`, `ViewModel`, `Repository`. + +##### **C. Contracts (Контракты): Тактические Спецификации** + +1. **Назначение:** Предоставление ИИ точных инструкций для генерации и валидации кода. +2. **Расположение:** Контракт всегда располагается **внутри открывающего якоря**, ДО декларации кода (`def` или `class`). +3. **Синтаксис:** JSDoc-подобный стиль с `@tag` для лаконичности и читаемости. + ```python + # + # @PURPOSE: Валидирует и обрабатывает входящие данные пользователя. + # @SPEC_LINK: tz-req-005 + # @PRE: `raw_data` не должен быть пустым. + # @POST: Возвращаемый словарь содержит ключ 'is_valid'. + # @PARAM: raw_data: Dict[str, any] - Сырые данные от пользователя. + # @RETURN: Dict[str, any] - Обработанные и валидированные данные. + # @TEST: input='{"user_id": 123}', expected_output='{"is_valid": True}' + # @THROW: ValueError - Если 'user_id' отсутствует. + # @RELATION: CALLS -> validate_user_id + # @CONSTRAINT: Не использовать внешние сетевые вызовы. + ``` +4. **Реализация в Коде:** Предусловия и постусловия, описанные в контракте, ДОЛЖНЫ быть реализованы в коде с использованием `assert`, `require()`/`check()` или явных `if...raise`. + +##### **G. Graph (Граф Знаний)** + +1. **Назначение:** Описание высокоуровневых зависимостей между сущностями. +2. **Реализация:** Граф определяется тегами `@RELATION` внутри GRACE блока (якоря). Это создает распределенный граф, который легко парсить. + * **Синтаксис:** `@: -> [опциональное описание]` + * **Таксономия Предикатов (``):** `DEPENDS_ON`, `CALLS`, `CREATES_INSTANCE_OF`, `INHERITS_FROM`, `IMPLEMENTS`, `READS_FROM`, `WRITES_TO`, `DISPATCHES_EVENT`, `OBSERVES`. + +##### **E. Evaluation (Логирование)** + +1. **Назначение:** Декларация `belief state` агента и обеспечение трассируемости для отладки. +2. **Формат:** `logger.level(f"[ANCHOR_ID][STATE] Сообщение")` + * **`ANCHOR_ID`:** `id` якоря, в котором находится лог. + * **`STATE`:** Текущее состояние логики (например, `Entry`, `Validation`, `Exit`, `CoherenceCheckFailed`). +3. **Пример:** `logger.debug(f"[process_data][Validation] Проверка `raw_data`...")` + +#### **IV. Запреты и Ограничения** + +1. **Запрет на Обычные Комментарии:** Комментарии в стиле `//` или `/* */` **ЗАПРЕЩЕНЫ**. Вся мета-информация должна быть в структурированных GRACE блоках. + * **Исключение:** `# [AI_NOTE]: ...` для прямых указаний агенту в конкретной точке кода. + +#### **V. Полный Пример Разметки Функции (GRACE-Py 2.2)** + +```python +# +# @PURPOSE: Валидирует и обрабатывает входящие данные пользователя. +# @SPEC_LINK: tz-req-005 +# @PRE: `raw_data` не должен быть пустым. +# @PARAM: raw_data: Dict[str, any] - Сырые данные от пользователя. +# @RETURN: Dict[str, any] - Обработанные и валидированные данные. +# @TEST: input='{}', expected_exception='AssertionError' +# @RELATION: CALLS -> some_helper_function +def process_data(raw_data: dict) -> dict: + """ + Docstring для стандартных инструментов Python. + Не является источником истины для ИИ-агентов. + """ + logger.debug(f"[process_data][Entry] Начало обработки данных.") + + # Реализация контракта + assert raw_data, "Precondition failed: raw_data must not be empty." + + # ... Основная логика ... + processed_data = {"is_valid": True} + processed_data.update(raw_data) + + logger.info(f"[process_data][CoherenceCheck:Passed] Код соответствует контракту.") + logger.debug(f"[process_data][Exit] Завершение обработки.") + + return processed_data +# +``` + diff --git a/superset_tool/client.py b/superset_tool/client.py index 4002edc..9fefc20 100644 --- a/superset_tool/client.py +++ b/superset_tool/client.py @@ -1,59 +1,38 @@ -# [MODULE_PATH] superset_tool.client -# [FILE] client.py -# [SEMANTICS] superset, api, client, logging, error-handling, slug-support +# +# @SEMANTICS: superset, api, client, rest, http, dashboard, dataset, import, export +# @PURPOSE: Предоставляет высокоуровневый клиент для взаимодействия с Superset REST API, инкапсулируя логику запросов, обработку ошибок и пагинацию. +# @DEPENDS_ON: superset_tool.models -> Использует SupersetConfig для конфигурации. +# @DEPENDS_ON: superset_tool.exceptions -> Выбрасывает специализированные исключения. +# @DEPENDS_ON: superset_tool.utils -> Использует утилиты для сети, логгирования и работы с файлами. -# -------------------------------------------------------------- -# [IMPORTS] -# -------------------------------------------------------------- +# import json import zipfile from pathlib import Path from typing import Any, Dict, List, Optional, Tuple, Union - from requests import Response - from superset_tool.models import SupersetConfig from superset_tool.exceptions import ExportError, InvalidZipFormatError from superset_tool.utils.fileio import get_filename_from_headers from superset_tool.utils.logger import SupersetLogger from superset_tool.utils.network import APIClient -# [END_IMPORTS] +# -# -------------------------------------------------------------- -# [ENTITY: Service('SupersetClient')] -# [RELATION: Service('SupersetClient')] -> [DEPENDS_ON] -> [PythonModule('superset_tool.utils.network')] -# -------------------------------------------------------------- -""" -:purpose: Класс‑обёртка над Superset REST‑API. -:preconditions: - - ``config`` – валидный объект :class:`SupersetConfig`. - - Доступен рабочий HTTP‑клиент :class:`APIClient`. -:postconditions: - - Объект готов к выполнению запросов (GET, POST, DELETE и т.д.). -:raises: - - :class:`TypeError` при передаче неверного типа конфигурации. -""" +# --- Начало кода модуля --- + +# +# @PURPOSE: Класс-обёртка над Superset REST API, предоставляющий методы для работы с дашбордами и датасетами. +# @RELATION: CREATES_INSTANCE_OF -> APIClient +# @RELATION: USES -> SupersetConfig class SupersetClient: - """ - :ivar SupersetLogger logger: Логгер, используемый в клиенте. - :ivar SupersetConfig config: Текущая конфигурация подключения. - :ivar APIClient network: Объект‑обёртка над ``requests``. - :ivar bool delete_before_reimport: Флаг, указывающий, - что при ошибке импорта дашборд следует удалить и повторить импорт. - """ - - # -------------------------------------------------------------- - # [ENTITY: Method('__init__')] - # -------------------------------------------------------------- - """ - :purpose: Инициализировать клиент и передать ему логгер. - :preconditions: ``config`` – экземпляр :class:`SupersetConfig`. - :postconditions: Атрибуты ``logger``, ``config`` и ``network`` созданы, - ``delete_before_reimport`` установлен в ``False``. - """ def __init__(self, config: SupersetConfig, logger: Optional[SupersetLogger] = None): + # + # @PURPOSE: Инициализирует клиент, проверяет конфигурацию и создает сетевой клиент. + # @PARAM: config: SupersetConfig - Конфигурация подключения. + # @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера. + # @POST: Атрибуты `logger`, `config`, и `network` созданы. self.logger = logger or SupersetLogger(name="SupersetClient") - self.logger.info("[INFO][SupersetClient.__init__] Initializing SupersetClient.") + self.logger.info("[SupersetClient.__init__][Enter] Initializing SupersetClient.") self._validate_config(config) self.config = config self.network = APIClient( @@ -63,68 +42,52 @@ class SupersetClient: logger=self.logger, ) self.delete_before_reimport: bool = False - self.logger.info("[INFO][SupersetClient.__init__] SupersetClient initialized.") - # [END_ENTITY] + self.logger.info("[SupersetClient.__init__][Exit] SupersetClient initialized.") + # - # -------------------------------------------------------------- - # [ENTITY: Method('_validate_config')] - # -------------------------------------------------------------- - """ - :purpose: Проверить, что передан объект :class:`SupersetConfig`. - :preconditions: ``config`` – произвольный объект. - :postconditions: При несовпадении типов возбуждается :class:`TypeError`. - """ + # + # @PURPOSE: Проверяет, что переданный объект конфигурации имеет корректный тип. + # @PARAM: config: SupersetConfig - Объект для проверки. + # @THROW: TypeError - Если `config` не является экземпляром `SupersetConfig`. def _validate_config(self, config: SupersetConfig) -> None: - self.logger.debug("[DEBUG][_validate_config][ENTER] Validating SupersetConfig.") - if not isinstance(config, SupersetConfig): - self.logger.error("[ERROR][_validate_config][FAILURE] Invalid config type.") - raise TypeError("Конфигурация должна быть экземпляром SupersetConfig") - self.logger.debug("[DEBUG][_validate_config][SUCCESS] Config is valid.") - # [END_ENTITY] + self.logger.debug("[_validate_config][Enter] Validating SupersetConfig.") + assert isinstance(config, SupersetConfig), "Конфигурация должна быть экземпляром SupersetConfig" + self.logger.debug("[_validate_config][Exit] Config is valid.") + # - # -------------------------------------------------------------- - # [ENTITY: Property('headers')] - # -------------------------------------------------------------- @property def headers(self) -> dict: - """Базовые HTTP‑заголовки, используемые клиентом.""" + # + # @PURPOSE: Возвращает базовые HTTP-заголовки, используемые сетевым клиентом. return self.network.headers - # [END_ENTITY] + # - # -------------------------------------------------------------- - # [ENTITY: Method('get_dashboards')] - # -------------------------------------------------------------- - """ - :purpose: Получить список дашбордов с поддержкой пагинации. - :preconditions: None. - :postconditions: Возвращается кортеж ``(total_count, list_of_dashboards)``. - """ + # + # @PURPOSE: Получает полный список дашбордов, автоматически обрабатывая пагинацию. + # @PARAM: query: Optional[Dict] - Дополнительные параметры запроса для API. + # @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список дашбордов). + # @RELATION: CALLS -> self._fetch_total_object_count + # @RELATION: CALLS -> self._fetch_all_pages def get_dashboards(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]: - self.logger.info("[INFO][get_dashboards][ENTER] Fetching dashboards.") + self.logger.info("[get_dashboards][Enter] Fetching dashboards.") validated_query = self._validate_query_params(query) total_count = self._fetch_total_object_count(endpoint="/dashboard/") paginated_data = self._fetch_all_pages( endpoint="/dashboard/", - pagination_options={ - "base_query": validated_query, - "total_count": total_count, - "results_field": "result", - }, + pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"}, ) - self.logger.info("[INFO][get_dashboards][SUCCESS] Got dashboards.") + self.logger.info("[get_dashboards][Exit] Found %d dashboards.", total_count) return total_count, paginated_data - # [END_ENTITY] + # - # -------------------------------------------------------------- - # [ENTITY: Method('export_dashboard')] - # -------------------------------------------------------------- - """ - :purpose: Скачать дашборд в виде ZIP‑архива. - :preconditions: ``dashboard_id`` – существующий идентификатор. - :postconditions: Возвращается бинарное содержимое и имя файла. - """ + # + # @PURPOSE: Экспортирует дашборд в виде ZIP-архива. + # @PARAM: dashboard_id: int - ID дашборда для экспорта. + # @RETURN: Tuple[bytes, str] - Бинарное содержимое ZIP-архива и имя файла. + # @THROW: ExportError - Если экспорт завершился неудачей. + # @RELATION: CALLS -> self.network.request def export_dashboard(self, dashboard_id: int) -> Tuple[bytes, str]: - self.logger.info("[INFO][export_dashboard][ENTER] Exporting dashboard %s.", dashboard_id) + self.logger.info("[export_dashboard][Enter] Exporting dashboard %s.", dashboard_id) response = self.network.request( method="GET", endpoint="/dashboard/export/", @@ -134,160 +97,86 @@ class SupersetClient: ) self._validate_export_response(response, dashboard_id) filename = self._resolve_export_filename(response, dashboard_id) - self.logger.info("[INFO][export_dashboard][SUCCESS] Exported dashboard %s.", dashboard_id) + self.logger.info("[export_dashboard][Exit] Exported dashboard %s to %s.", dashboard_id, filename) return response.content, filename - # [END_ENTITY] + # - # -------------------------------------------------------------- - # [ENTITY: Method('import_dashboard')] - # -------------------------------------------------------------- - """ - :purpose: Импортировать дашборд из ZIP‑файла. При неуспешном импорте, - если ``delete_before_reimport`` = True, сначала удаляется - дашборд по ID, затем импорт повторяется. - :preconditions: - - ``file_name`` – путь к существующему ZIP‑архиву (str|Path). - - ``dash_id`` – (опционально) ID дашборда, который следует удалить. - :postconditions: Возвращается словарь‑ответ API при успехе. - """ - def import_dashboard( - self, - file_name: Union[str, Path], - dash_id: Optional[int] = None, - dash_slug: Optional[str] = None, # сохраняем для возможного логирования - ) -> Dict: - # ----------------------------------------------------------------- - # 1️⃣ Приводим путь к строке (API‑клиент ожидает str) - # ----------------------------------------------------------------- - file_path: str = str(file_name) # <--- гарантируем тип str + # + # @PURPOSE: Импортирует дашборд из ZIP-файла с возможностью автоматического удаления и повторной попытки при ошибке. + # @PARAM: file_name: Union[str, Path] - Путь к ZIP-архиву. + # @PARAM: dash_id: Optional[int] - ID дашборда для удаления при сбое. + # @PARAM: dash_slug: Optional[str] - Slug дашборда для поиска ID, если ID не предоставлен. + # @RETURN: Dict - Ответ API в случае успеха. + # @RELATION: CALLS -> self._do_import + # @RELATION: CALLS -> self.delete_dashboard + # @RELATION: CALLS -> self.get_dashboards + def import_dashboard(self, file_name: Union[str, Path], dash_id: Optional[int] = None, dash_slug: Optional[str] = None) -> Dict: + file_path = str(file_name) self._validate_import_file(file_path) - try: - import_response = self._do_import(file_path) - self.logger.info("[INFO][import_dashboard] Imported %s.", file_path) - return import_response - + return self._do_import(file_path) except Exception as exc: - # ----------------------------------------------------------------- - # 2️⃣ Логируем первую неудачу, пытаемся удалить и повторить, - # только если включён флаг ``delete_before_reimport``. - # ----------------------------------------------------------------- - self.logger.error( - "[ERROR][import_dashboard] First import attempt failed: %s", - exc, - exc_info=True, - ) + self.logger.error("[import_dashboard][Failure] First import attempt failed: %s", exc, exc_info=True) if not self.delete_before_reimport: raise - # ----------------------------------------------------------------- - # 3️⃣ Выбираем, как искать дашборд для удаления. - # При наличии ``dash_id`` – удаляем его. - # Иначе, если известен ``dash_slug`` – переводим его в ID ниже. - # ----------------------------------------------------------------- - target_id: Optional[int] = dash_id - if target_id is None and dash_slug is not None: - # Попытка динамического определения ID через slug. - # Мы делаем отдельный запрос к /dashboard/ (поисковый фильтр). - self.logger.debug("[DEBUG][import_dashboard] Resolving ID by slug '%s'.", dash_slug) - try: - _, candidates = self.get_dashboards( - query={"filters": [{"col": "slug", "op": "eq", "value": dash_slug}]} - ) - if candidates: - target_id = candidates[0]["id"] - self.logger.debug("[DEBUG][import_dashboard] Resolved slug → ID %s.", target_id) - except Exception as e: - self.logger.warning( - "[WARN][import_dashboard] Could not resolve slug '%s' to ID: %s", - dash_slug, - e, - ) - - # Если всё‑равно нет ID – считаем невозможным корректно удалить. + target_id = self._resolve_target_id_for_delete(dash_id, dash_slug) if target_id is None: - self.logger.error("[ERROR][import_dashboard] No ID available for delete‑retry.") + self.logger.error("[import_dashboard][Failure] No ID available for delete-retry.") raise - # ----------------------------------------------------------------- - # 4️⃣ Удаляем найденный дашборд (по ID) - # ----------------------------------------------------------------- + self.delete_dashboard(target_id) + self.logger.info("[import_dashboard][State] Deleted dashboard ID %s, retrying import.", target_id) + return self._do_import(file_path) + # + + # + # @PURPOSE: Определяет ID дашборда для удаления, используя ID или slug. + # @INTERNAL + def _resolve_target_id_for_delete(self, dash_id: Optional[int], dash_slug: Optional[str]) -> Optional[int]: + if dash_id is not None: + return dash_id + if dash_slug is not None: + self.logger.debug("[_resolve_target_id_for_delete][State] Resolving ID by slug '%s'.", dash_slug) try: - self.delete_dashboard(target_id) - self.logger.info("[INFO][import_dashboard] Deleted dashboard ID %s, retrying import.", target_id) - except Exception as del_exc: - self.logger.error("[ERROR][import_dashboard] Delete failed: %s", del_exc, exc_info=True) - raise + _, candidates = self.get_dashboards(query={"filters": [{"col": "slug", "op": "eq", "value": dash_slug}]}) + if candidates: + target_id = candidates[0]["id"] + self.logger.debug("[_resolve_target_id_for_delete][Success] Resolved slug to ID %s.", target_id) + return target_id + except Exception as e: + self.logger.warning("[_resolve_target_id_for_delete][Warning] Could not resolve slug '%s' to ID: %s", dash_slug, e) + return None + # - # ----------------------------------------------------------------- - # 5️⃣ Повторный импорт (тот же файл) - # ----------------------------------------------------------------- - try: - import_response = self._do_import(file_path) - self.logger.info("[INFO][import_dashboard] Re‑import succeeded.") - return import_response - except Exception as rec_exc: - self.logger.error( - "[ERROR][import_dashboard] Re‑import after delete failed: %s", - rec_exc, - exc_info=True, - ) - raise - # [END_ENTITY] - - # -------------------------------------------------------------- - # [ENTITY: Method('_do_import')] - # -------------------------------------------------------------- - """ - :purpose: Выполнить один запрос на импорт без обработки исключений. - :preconditions: ``file_name`` уже проверен и существует. - :postconditions: Возвращается словарь‑ответ API. - """ + # + # @PURPOSE: Выполняет один запрос на импорт без обработки исключений. + # @INTERNAL def _do_import(self, file_name: Union[str, Path]) -> Dict: return self.network.upload_file( endpoint="/dashboard/import/", - file_info={ - "file_obj": Path(file_name), - "file_name": Path(file_name).name, - "form_field": "formData", - }, + file_info={"file_obj": Path(file_name), "file_name": Path(file_name).name, "form_field": "formData"}, extra_data={"overwrite": "true"}, timeout=self.config.timeout * 2, ) - # [END_ENTITY] + # - # -------------------------------------------------------------- - # [ENTITY: Method('delete_dashboard')] - # -------------------------------------------------------------- - """ - :purpose: Удалить дашборд **по ID или slug**. - :preconditions: - - ``dashboard_id`` – int ID **или** str slug дашборда. - :postconditions: На уровне API считается, что ресурс удалён - (HTTP 200/204). Логируется результат операции. - """ + # + # @PURPOSE: Удаляет дашборд по его ID или slug. + # @PARAM: dashboard_id: Union[int, str] - ID или slug дашборда. + # @RELATION: CALLS -> self.network.request def delete_dashboard(self, dashboard_id: Union[int, str]) -> None: - # ``dashboard_id`` может быть целым числом или строковым slug. - self.logger.info("[INFO][delete_dashboard][ENTER] Deleting dashboard %s.", dashboard_id) - response = self.network.request( - method="DELETE", - endpoint=f"/dashboard/{dashboard_id}", - ) - # Superset обычно возвращает 200/204. Если есть поле ``result`` – проверяем. + self.logger.info("[delete_dashboard][Enter] Deleting dashboard %s.", dashboard_id) + response = self.network.request(method="DELETE", endpoint=f"/dashboard/{dashboard_id}") if response.get("result", True) is not False: - self.logger.info("[INFO][delete_dashboard] Dashboard %s deleted.", dashboard_id) + self.logger.info("[delete_dashboard][Success] Dashboard %s deleted.", dashboard_id) else: - self.logger.warning("[WARN][delete_dashboard] Unexpected response while deleting %s.", dashboard_id) - # [END_ENTITY] + self.logger.warning("[delete_dashboard][Warning] Unexpected response while deleting %s: %s", dashboard_id, response) + # - # -------------------------------------------------------------- - # [ENTITY: Method('_extract_dashboard_id_from_zip')] - # -------------------------------------------------------------- - """ - :purpose: Попытаться извлечь **ID** дашборда из ``metadata.yaml`` внутри ZIP‑архива. - :preconditions: ``file_name`` – путь к корректному ZIP‑файлу. - :postconditions: Возвращается ``int`` ID или ``None``. - """ + # + # @PURPOSE: Извлекает ID дашборда из `metadata.yaml` внутри ZIP-архива. + # @INTERNAL def _extract_dashboard_id_from_zip(self, file_name: Union[str, Path]) -> Optional[int]: try: import yaml @@ -295,23 +184,17 @@ class SupersetClient: for name in zf.namelist(): if name.endswith("metadata.yaml"): with zf.open(name) as meta_file: - meta = yaml.safe_load(meta_file.read()) + meta = yaml.safe_load(meta_file) dash_id = meta.get("dashboard_uuid") or meta.get("dashboard_id") - if dash_id is not None: - return int(dash_id) + if dash_id: return int(dash_id) except Exception as exc: - self.logger.error("[ERROR][_extract_dashboard_id_from_zip] %s", exc, exc_info=True) + self.logger.error("[_extract_dashboard_id_from_zip][Failure] %s", exc, exc_info=True) return None - # [END_ENTITY] + # - # -------------------------------------------------------------- - # [ENTITY: Method('_extract_dashboard_slug_from_zip')] - # -------------------------------------------------------------- - """ - :purpose: Попытаться извлечь **slug** дашборда из ``metadata.yaml`` внутри ZIP‑архива. - :preconditions: ``file_name`` – путь к корректному ZIP‑файлу. - :postconditions: Возвращается строка‑slug или ``None``. - """ + # + # @PURPOSE: Извлекает slug дашборда из `metadata.yaml` внутри ZIP-архива. + # @INTERNAL def _extract_dashboard_slug_from_zip(self, file_name: Union[str, Path]) -> Optional[str]: try: import yaml @@ -319,158 +202,128 @@ class SupersetClient: for name in zf.namelist(): if name.endswith("metadata.yaml"): with zf.open(name) as meta_file: - meta = yaml.safe_load(meta_file.read()) - slug = meta.get("slug") - if slug: + meta = yaml.safe_load(meta_file) + if slug := meta.get("slug"): return str(slug) except Exception as exc: - self.logger.error("[ERROR][_extract_dashboard_slug_from_zip] %s", exc, exc_info=True) + self.logger.error("[_extract_dashboard_slug_from_zip][Failure] %s", exc, exc_info=True) return None - # [END_ENTITY] + # - # -------------------------------------------------------------- - # [ENTITY: Method('_validate_export_response')] - # -------------------------------------------------------------- - """ - :purpose: Проверить, что ответ от ``/dashboard/export/`` – ZIP‑архив с данными. - :preconditions: ``response`` – объект :class:`requests.Response`. - :postconditions: При несоответствии возбуждается :class:`ExportError`. - """ + # + # @PURPOSE: Проверяет, что HTTP-ответ на экспорт является валидным ZIP-архивом. + # @INTERNAL + # @THROW: ExportError - Если ответ не является ZIP-архивом или пуст. def _validate_export_response(self, response: Response, dashboard_id: int) -> None: - self.logger.debug("[DEBUG][_validate_export_response][ENTER] Validating response for %s.", dashboard_id) content_type = response.headers.get("Content-Type", "") if "application/zip" not in content_type: - self.logger.error("[ERROR][_validate_export_response][FAILURE] Invalid content type: %s", content_type) - raise ExportError(f"Получен не ZIP‑архив (Content-Type: {content_type})") + raise ExportError(f"Получен не ZIP-архив (Content-Type: {content_type})") if not response.content: - self.logger.error("[ERROR][_validate_export_response][FAILURE] Empty response content.") raise ExportError("Получены пустые данные при экспорте") - self.logger.debug("[DEBUG][_validate_export_response][SUCCESS] Response validated.") - # [END_ENTITY] + # - # -------------------------------------------------------------- - # [ENTITY: Method('_resolve_export_filename')] - # -------------------------------------------------------------- - """ - :purpose: Определить имя файла, полученного из заголовков ответа. - :preconditions: ``response.headers`` содержит (возможно) ``Content‑Disposition``. - :postconditions: Возвращается строка‑имя файла. - """ + # + # @PURPOSE: Определяет имя файла для экспорта из заголовков или генерирует его. + # @INTERNAL def _resolve_export_filename(self, response: Response, dashboard_id: int) -> str: - self.logger.debug("[DEBUG][_resolve_export_filename][ENTER] Resolving filename.") filename = get_filename_from_headers(response.headers) if not filename: from datetime import datetime timestamp = datetime.now().strftime("%Y%m%dT%H%M%S") filename = f"dashboard_export_{dashboard_id}_{timestamp}.zip" - self.logger.warning("[WARN][_resolve_export_filename] Generated filename: %s", filename) - self.logger.debug("[DEBUG][_resolve_export_filename][SUCCESS] Filename: %s", filename) + self.logger.warning("[_resolve_export_filename][Warning] Generated filename: %s", filename) return filename - # [END_ENTITY] + # - # -------------------------------------------------------------- - # [ENTITY: Method('_validate_query_params')] - # -------------------------------------------------------------- - """ - :purpose: Сформировать корректный набор параметров запроса. - :preconditions: ``query`` – любой словарь или ``None``. - :postconditions: Возвращается словарь с обязательными полями. - """ + # + # @PURPOSE: Формирует корректный набор параметров запроса с пагинацией. + # @INTERNAL def _validate_query_params(self, query: Optional[Dict]) -> Dict: - base_query = { - "columns": ["slug", "id", "changed_on_utc", "dashboard_title", "published"], - "page": 0, - "page_size": 1000, - } - validated = {**base_query, **(query or {})} - self.logger.debug("[DEBUG][_validate_query_params] %s", validated) - return validated - # [END_ENTITY] + base_query = {"columns": ["slug", "id", "changed_on_utc", "dashboard_title", "published"], "page": 0, "page_size": 1000} + return {**base_query, **(query or {})} + # - # -------------------------------------------------------------- - # [ENTITY: Method('_fetch_total_object_count')] - # -------------------------------------------------------------- - """ - :purpose: Получить общее количество объектов по указанному endpoint. - :preconditions: ``endpoint`` – строка, начинающаяся с «/». - :postconditions: Возвращается целое число. - """ + # + # @PURPOSE: Получает общее количество объектов по указанному эндпоинту для пагинации. + # @INTERNAL def _fetch_total_object_count(self, endpoint: str) -> int: - query_params_for_count = {"page": 0, "page_size": 1} - count = self.network.fetch_paginated_count( + return self.network.fetch_paginated_count( endpoint=endpoint, - query_params=query_params_for_count, + query_params={"page": 0, "page_size": 1}, count_field="count", ) - self.logger.debug("[DEBUG][_fetch_total_object_count] %s → %s", endpoint, count) - return count - # [END_ENTITY] + # - # -------------------------------------------------------------- - # [ENTITY: Method('_fetch_all_pages')] - # -------------------------------------------------------------- - """ - :purpose: Обойти все страницы пагинированного API. - :preconditions: ``pagination_options`` – словарь, сформированный - в ``_validate_query_params`` и ``_fetch_total_object_count``. - :postconditions: Возвращается список всех объектов. - """ + # + # @PURPOSE: Итерируется по всем страницам пагинированного API и собирает все данные. + # @INTERNAL def _fetch_all_pages(self, endpoint: str, pagination_options: Dict) -> List[Dict]: - all_data = self.network.fetch_paginated_data( - endpoint=endpoint, - pagination_options=pagination_options, - ) - self.logger.debug("[DEBUG][_fetch_all_pages] Fetched %s items from %s.", len(all_data), endpoint) - return all_data - # [END_ENTITY] + return self.network.fetch_paginated_data(endpoint=endpoint, pagination_options=pagination_options) + # - # -------------------------------------------------------------- - # [ENTITY: Method('_validate_import_file')] - # -------------------------------------------------------------- - """ - :purpose: Проверить, что файл существует, является ZIP‑архивом и - содержит ``metadata.yaml``. - :preconditions: ``zip_path`` – путь к файлу. - :postconditions: При невалидном файле возбуждается :class:`InvalidZipFormatError`. - """ + # + # @PURPOSE: Проверяет, что файл существует, является ZIP-архивом и содержит `metadata.yaml`. + # @INTERNAL + # @THROW: FileNotFoundError - Если файл не найден. + # @THROW: InvalidZipFormatError - Если файл не является ZIP или не содержит `metadata.yaml`. def _validate_import_file(self, zip_path: Union[str, Path]) -> None: path = Path(zip_path) - if not path.exists(): - self.logger.error("[ERROR][_validate_import_file] File not found: %s", zip_path) - raise FileNotFoundError(f"Файл {zip_path} не существует") - if not zipfile.is_zipfile(path): - self.logger.error("[ERROR][_validate_import_file] Not a zip file: %s", zip_path) - raise InvalidZipFormatError(f"Файл {zip_path} не является ZIP‑архивом") + assert path.exists(), f"Файл {zip_path} не существует" + assert zipfile.is_zipfile(path), f"Файл {zip_path} не является ZIP-архивом" with zipfile.ZipFile(path, "r") as zf: - if not any(n.endswith("metadata.yaml") for n in zf.namelist()): - self.logger.error("[ERROR][_validate_import_file] No metadata.yaml in %s", zip_path) - raise InvalidZipFormatError(f"Архив {zip_path} не содержит 'metadata.yaml'") - self.logger.debug("[DEBUG][_validate_import_file] File %s validated.", zip_path) - # [END_ENTITY] - # -------------------------------------------------------------- - # [ENTITY: Method('get_datasets')] - # -------------------------------------------------------------- - """ - :purpose: Получить список датасетов с поддержкой пагинации. - :preconditions: None. - :postconditions: Возвращается кортеж ``(total_count, list_of_datasets)``. - """ + assert any(n.endswith("metadata.yaml") for n in zf.namelist()), f"Архив {zip_path} не содержит 'metadata.yaml'" + # + # + # @PURPOSE: Получает полный список датасетов, автоматически обрабатывая пагинацию. + # @PARAM: query: Optional[Dict] - Дополнительные параметры запроса для API. + # @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список датасетов). + # @RELATION: CALLS -> self._fetch_total_object_count + # @RELATION: CALLS -> self._fetch_all_pages def get_datasets(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]: - self.logger.info("[INFO][get_datasets][ENTER] Fetching datasets.") + self.logger.info("[get_datasets][Enter] Fetching datasets.") validated_query = self._validate_query_params(query) total_count = self._fetch_total_object_count(endpoint="/dataset/") paginated_data = self._fetch_all_pages( endpoint="/dataset/", - pagination_options={ - "base_query": validated_query, - "total_count": total_count, - "results_field": "result", - }, + pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"}, ) - self.logger.info("[INFO][get_datasets][SUCCESS] Got datasets.") + self.logger.info("[get_datasets][Exit] Found %d datasets.", total_count) return total_count, paginated_data - # [END_ENTITY] + # + # + # @PURPOSE: Получает информацию о конкретном датасете по его ID. + # @PARAM: dataset_id: int - ID датасета. + # @RETURN: Dict - Словарь с информацией о датасете. + # @RELATION: CALLS -> self.network.request + def get_dataset(self, dataset_id: int) -> Dict: + self.logger.info("[get_dataset][Enter] Fetching dataset %s.", dataset_id) + response = self.network.request(method="GET", endpoint=f"/dataset/{dataset_id}") + self.logger.info("[get_dataset][Exit] Got dataset %s.", dataset_id) + return response + # -# [END_FILE client.py] \ No newline at end of file + # + # @PURPOSE: Обновляет данные датасета по его ID. + # @PARAM: dataset_id: int - ID датасета для обновления. + # @PARAM: data: Dict - Словарь с данными для обновления. + # @RETURN: Dict - Ответ API. + # @RELATION: CALLS -> self.network.request + def update_dataset(self, dataset_id: int, data: Dict) -> Dict: + self.logger.info("[update_dataset][Enter] Updating dataset %s.", dataset_id) + response = self.network.request( + method="PUT", + endpoint=f"/dataset/{dataset_id}", + data=json.dumps(data), + headers={'Content-Type': 'application/json'} + ) + self.logger.info("[update_dataset][Exit] Updated dataset %s.", dataset_id) + return response + # + +# + +# --- Конец кода модуля --- + +# \ No newline at end of file diff --git a/superset_tool/exceptions.py b/superset_tool/exceptions.py index e371190..5502d87 100644 --- a/superset_tool/exceptions.py +++ b/superset_tool/exceptions.py @@ -1,124 +1,110 @@ -# pylint: disable=too-many-ancestors -""" -[MODULE] Иерархия исключений -@contract: Все ошибки наследуют `SupersetToolError` для единой точки обработки. -""" +# +# @SEMANTICS: exception, error, hierarchy +# @PURPOSE: Определяет иерархию пользовательских исключений для всего инструмента, обеспечивая единую точку обработки ошибок. +# @RELATION: ALL_CLASSES -> INHERITS_FROM -> SupersetToolError (or other exception in this module) -# [IMPORTS] Standard library +# from pathlib import Path - -# [IMPORTS] Typing from typing import Optional, Dict, Any, Union +# +# --- Начало кода модуля --- + +# +# @PURPOSE: Базовый класс для всех ошибок, генерируемых инструментом. +# @INHERITS_FROM: Exception class SupersetToolError(Exception): - """[BASE] Базовый класс для всех ошибок инструмента Superset.""" - # [ENTITY: Function('__init__')] - # CONTRACT: - # PURPOSE: Инициализация базового исключения. - # PRECONDITIONS: `context` должен быть словарем или None. - # POSTCONDITIONS: Исключение создано с сообщением и контекстом. def __init__(self, message: str, context: Optional[Dict[str, Any]] = None): - if not isinstance(context, (dict, type(None))): - raise TypeError("Контекст ошибки должен быть словарем или None") self.context = context or {} super().__init__(f"{message} | Context: {self.context}") - # END_FUNCTION___init__ +# +# +# @PURPOSE: Ошибки, связанные с аутентификацией или авторизацией. +# @INHERITS_FROM: SupersetToolError class AuthenticationError(SupersetToolError): - """[AUTH] Ошибки аутентификации или авторизации.""" - # [ENTITY: Function('__init__')] - # CONTRACT: - # PURPOSE: Инициализация исключения аутентификации. - # PRECONDITIONS: None - # POSTCONDITIONS: Исключение создано. def __init__(self, message: str = "Authentication failed", **context: Any): super().__init__(f"[AUTH_FAILURE] {message}", context={"type": "authentication", **context}) - # END_FUNCTION___init__ +# +# +# @PURPOSE: Ошибка, возникающая при отказе в доступе к ресурсу. +# @INHERITS_FROM: AuthenticationError class PermissionDeniedError(AuthenticationError): - """[AUTH] Ошибка отказа в доступе.""" - # [ENTITY: Function('__init__')] - # CONTRACT: - # PURPOSE: Инициализация исключения отказа в доступе. - # PRECONDITIONS: None - # POSTCONDITIONS: Исключение создано. def __init__(self, message: str = "Permission denied", required_permission: Optional[str] = None, **context: Any): full_message = f"Permission denied: {required_permission}" if required_permission else message super().__init__(full_message, context={"required_permission": required_permission, **context}) - # END_FUNCTION___init__ +# +# +# @PURPOSE: Общие ошибки при взаимодействии с Superset API. +# @INHERITS_FROM: SupersetToolError class SupersetAPIError(SupersetToolError): - """[API] Общие ошибки взаимодействия с Superset API.""" - # [ENTITY: Function('__init__')] - # CONTRACT: - # PURPOSE: Инициализация исключения ошибки API. - # PRECONDITIONS: None - # POSTCONDITIONS: Исключение создано. def __init__(self, message: str = "Superset API error", **context: Any): super().__init__(f"[API_FAILURE] {message}", context={"type": "api_call", **context}) - # END_FUNCTION___init__ +# +# +# @PURPOSE: Ошибки, специфичные для операций экспорта. +# @INHERITS_FROM: SupersetAPIError class ExportError(SupersetAPIError): - """[API:EXPORT] Проблемы, специфичные для операций экспорта.""" - # [ENTITY: Function('__init__')] - # CONTRACT: - # PURPOSE: Инициализация исключения ошибки экспорта. - # PRECONDITIONS: None - # POSTCONDITIONS: Исключение создано. def __init__(self, message: str = "Dashboard export failed", **context: Any): super().__init__(f"[EXPORT_FAILURE] {message}", context={"subtype": "export", **context}) - # END_FUNCTION___init__ +# +# +# @PURPOSE: Ошибка, когда запрошенный дашборд или ресурс не найден (404). +# @INHERITS_FROM: SupersetAPIError class DashboardNotFoundError(SupersetAPIError): - """[API:404] Запрошенный дашборд или ресурс не существует.""" - # [ENTITY: Function('__init__')] - # CONTRACT: - # PURPOSE: Инициализация исключения "дашборд не найден". - # PRECONDITIONS: None - # POSTCONDITIONS: Исключение создано. def __init__(self, dashboard_id_or_slug: Union[int, str], message: str = "Dashboard not found", **context: Any): super().__init__(f"[NOT_FOUND] Dashboard '{dashboard_id_or_slug}' {message}", context={"subtype": "not_found", "resource_id": dashboard_id_or_slug, **context}) - # END_FUNCTION___init__ +# +# +# @PURPOSE: Ошибка, когда запрашиваемый набор данных не существует (404). +# @INHERITS_FROM: SupersetAPIError class DatasetNotFoundError(SupersetAPIError): - """[API:404] Запрашиваемый набор данных не существует.""" - # [ENTITY: Function('__init__')] - # CONTRACT: - # PURPOSE: Инициализация исключения "набор данных не найден". - # PRECONDITIONS: None - # POSTCONDITIONS: Исключение создано. def __init__(self, dataset_id_or_slug: Union[int, str], message: str = "Dataset not found", **context: Any): super().__init__(f"[NOT_FOUND] Dataset '{dataset_id_or_slug}' {message}", context={"subtype": "not_found", "resource_id": dataset_id_or_slug, **context}) - # END_FUNCTION___init__ +# +# +# @PURPOSE: Ошибка, указывающая на некорректный формат или содержимое ZIP-архива. +# @INHERITS_FROM: SupersetToolError class InvalidZipFormatError(SupersetToolError): - """[FILE:ZIP] Некорректный формат ZIP-архива.""" - # [ENTITY: Function('__init__')] - # CONTRACT: - # PURPOSE: Инициализация исключения некорректного формата ZIP. - # PRECONDITIONS: None - # POSTCONDITIONS: Исключение создано. def __init__(self, message: str = "Invalid ZIP format or content", file_path: Optional[Union[str, Path]] = None, **context: Any): super().__init__(f"[FILE_ERROR] {message}", context={"type": "file_validation", "file_path": str(file_path) if file_path else "N/A", **context}) - # END_FUNCTION___init__ +# +# +# @PURPOSE: Ошибки, связанные с сетевым соединением. +# @INHERITS_FROM: SupersetToolError class NetworkError(SupersetToolError): - """[NETWORK] Проблемы соединения.""" - # [ENTITY: Function('__init__')] - # CONTRACT: - # PURPOSE: Инициализация исключения сетевой ошибки. - # PRECONDITIONS: None - # POSTCONDITIONS: Исключение создано. def __init__(self, message: str = "Network connection failed", **context: Any): super().__init__(f"[NETWORK_FAILURE] {message}", context={"type": "network", **context}) - # END_FUNCTION___init__ +# +# +# @PURPOSE: Общие ошибки файловых операций (I/O). +# @INHERITS_FROM: SupersetToolError class FileOperationError(SupersetToolError): - """[FILE] Ошибка файловых операций.""" + pass +# +# +# @PURPOSE: Ошибка, указывающая на некорректную структуру файлов или директорий. +# @INHERITS_FROM: FileOperationError class InvalidFileStructureError(FileOperationError): - """[FILE] Некорректная структура файлов/директорий.""" + pass +# +# +# @PURPOSE: Ошибки, связанные с неверной конфигурацией инструмента. +# @INHERITS_FROM: SupersetToolError class ConfigurationError(SupersetToolError): - """[CONFIG] Ошибка в конфигурации инструмента.""" + pass +# +# --- Конец кода модуля --- + +# diff --git a/superset_tool/models.py b/superset_tool/models.py index 1a4c408..1ee832b 100644 --- a/superset_tool/models.py +++ b/superset_tool/models.py @@ -1,91 +1,82 @@ -# pylint: disable=no-self-argument,too-few-public-methods -""" -[MODULE] Сущности данных конфигурации -@desc: Определяет структуры данных, используемые для конфигурации и трансформации в инструменте Superset. -""" +# +# @SEMANTICS: pydantic, model, config, validation, data-structure +# @PURPOSE: Определяет Pydantic-модели для конфигурации инструмента, обеспечивая валидацию данных. +# @DEPENDS_ON: pydantic -> Для создания моделей и валидации. +# @DEPENDS_ON: superset_tool.utils.logger -> Для логирования в процессе валидации. -# [IMPORTS] Pydantic и Typing +# import re from typing import Optional, Dict, Any -from pydantic import BaseModel, validator, Field, HttpUrl, VERSION - -# [IMPORTS] Локальные модули +from pydantic import BaseModel, validator, Field from .utils.logger import SupersetLogger +# +# --- Начало кода модуля --- + +# +# @PURPOSE: Модель конфигурации для подключения к одному экземпляру Superset API. +# @INHERITS_FROM: pydantic.BaseModel class SupersetConfig(BaseModel): - """ - [CONFIG] Конфигурация подключения к Superset API. - """ env: str = Field(..., description="Название окружения (например, dev, prod).") - base_url: str = Field(..., description="Базовый URL Superset API, включая версию /api/v1.", pattern=r'.*/api/v1.*') + base_url: str = Field(..., description="Базовый URL Superset API, включая /api/v1.") auth: Dict[str, str] = Field(..., description="Словарь с данными для аутентификации (provider, username, password, refresh).") verify_ssl: bool = Field(True, description="Флаг для проверки SSL-сертификатов.") timeout: int = Field(30, description="Таймаут в секундах для HTTP-запросов.") - logger: Optional[SupersetLogger] = Field(None, description="Экземпляр логгера для логирования внутри клиента.") + logger: Optional[SupersetLogger] = Field(None, description="Экземпляр логгера для логирования.") - # [ENTITY: Function('validate_auth')] - # CONTRACT: - # PURPOSE: Валидация словаря `auth`. - # PRECONDITIONS: `v` должен быть словарем. - # POSTCONDITIONS: Возвращает `v` если все обязательные поля присутствуют. + # + # @PURPOSE: Проверяет, что словарь `auth` содержит все необходимые для аутентификации поля. + # @PRE: `v` должен быть словарем. + # @POST: Возвращает `v`, если все обязательные поля (`provider`, `username`, `password`, `refresh`) присутствуют. + # @THROW: ValueError - Если отсутствуют обязательные поля. @validator('auth') - def validate_auth(cls, v: Dict[str, str], values: dict) -> Dict[str, str]: - logger = values.get('logger') or SupersetLogger(name="SupersetConfig") - logger.debug("[DEBUG][SupersetConfig.validate_auth][ENTER] Validating auth.") + def validate_auth(cls, v: Dict[str, str]) -> Dict[str, str]: required = {'provider', 'username', 'password', 'refresh'} if not required.issubset(v.keys()): - logger.error("[ERROR][SupersetConfig.validate_auth][FAILURE] Missing required auth fields.") raise ValueError(f"Словарь 'auth' должен содержать поля: {required}. Отсутствующие: {required - v.keys()}") - logger.debug("[DEBUG][SupersetConfig.validate_auth][SUCCESS] Auth validated.") return v - # END_FUNCTION_validate_auth + # - # [ENTITY: Function('check_base_url_format')] - # CONTRACT: - # PURPOSE: Валидация формата `base_url`. - # PRECONDITIONS: `v` должна быть строкой. - # POSTCONDITIONS: Возвращает `v` если это валидный URL. + # + # @PURPOSE: Проверяет, что `base_url` соответствует формату URL и содержит `/api/v1`. + # @PRE: `v` должна быть строкой. + # @POST: Возвращает очищенный `v`, если формат корректен. + # @THROW: ValueError - Если формат URL невалиден. @validator('base_url') - def check_base_url_format(cls, v: str, values: dict) -> str: - """ - Простейшая проверка: - - начинается с http/https, - - содержит «/api/v1», - - не содержит пробельных символов в начале/конце. - """ - v = v.strip() # устраняем скрытые пробелы/переносы + def check_base_url_format(cls, v: str) -> str: + v = v.strip() if not re.fullmatch(r'https?://.+/api/v1/?(?:.*)?', v): - raise ValueError(f"Invalid URL format: {v}") + raise ValueError(f"Invalid URL format: {v}. Must include '/api/v1'.") return v - # END_FUNCTION_check_base_url_format + # class Config: - """Pydantic config""" arbitrary_types_allowed = True +# +# +# @PURPOSE: Модель для параметров трансформации баз данных при миграции дашбордов. +# @INHERITS_FROM: pydantic.BaseModel class DatabaseConfig(BaseModel): - """ - [CONFIG] Параметры трансформации баз данных при миграции дашбордов. - """ database_config: Dict[str, Dict[str, Any]] = Field(..., description="Словарь, содержащий 'old' и 'new' конфигурации базы данных.") logger: Optional[SupersetLogger] = Field(None, description="Экземпляр логгера для логирования.") - # [ENTITY: Function('validate_config')] - # CONTRACT: - # PURPOSE: Валидация словаря `database_config`. - # PRECONDITIONS: `v` должен быть словарем. - # POSTCONDITIONS: Возвращает `v` если содержит ключи 'old' и 'new'. + # + # @PURPOSE: Проверяет, что словарь `database_config` содержит ключи 'old' и 'new'. + # @PRE: `v` должен быть словарем. + # @POST: Возвращает `v`, если ключи 'old' и 'new' присутствуют. + # @THROW: ValueError - Если отсутствуют обязательные ключи. @validator('database_config') - def validate_config(cls, v: Dict[str, Dict[str, Any]], values: dict) -> Dict[str, Dict[str, Any]]: - logger = values.get('logger') or SupersetLogger(name="DatabaseConfig") - logger.debug("[DEBUG][DatabaseConfig.validate_config][ENTER] Validating database_config.") + def validate_config(cls, v: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: if not {'old', 'new'}.issubset(v.keys()): - logger.error("[ERROR][DatabaseConfig.validate_config][FAILURE] Missing 'old' or 'new' keys in database_config.") raise ValueError("'database_config' должен содержать ключи 'old' и 'new'.") - logger.debug("[DEBUG][DatabaseConfig.validate_config][SUCCESS] database_config validated.") return v - # END_FUNCTION_validate_config + # class Config: - """Pydantic config""" arbitrary_types_allowed = True +# + +# --- Конец кода модуля --- + +# diff --git a/superset_tool/utils/fileio.py b/superset_tool/utils/fileio.py index 73e10e1..89e3788 100644 --- a/superset_tool/utils/fileio.py +++ b/superset_tool/utils/fileio.py @@ -1,11 +1,11 @@ -# -*- coding: utf-8 -*- -# pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument -""" -[MODULE] File Operations Manager -@contract: Предоставляет набор утилит для управления файловыми операциями. -""" +# +# @SEMANTICS: file, io, zip, yaml, temp, archive, utility +# @PURPOSE: Предоставляет набор утилит для управления файловыми операциями, включая работу с временными файлами, архивами ZIP, файлами YAML и очистку директорий. +# @DEPENDS_ON: superset_tool.exceptions -> Для генерации специфичных ошибок. +# @DEPENDS_ON: superset_tool.utils.logger -> Для логирования операций. +# @DEPENDS_ON: pyyaml -> Для работы с YAML файлами. -# [IMPORTS] Core +# import os import re import zipfile @@ -18,661 +18,264 @@ import glob import shutil import zlib from dataclasses import dataclass - -# [IMPORTS] Third-party import yaml - -# [IMPORTS] Local from superset_tool.exceptions import InvalidZipFormatError from superset_tool.utils.logger import SupersetLogger +# -# [CONSTANTS] -ALLOWED_FOLDERS = {'databases', 'datasets', 'charts', 'dashboards'} +# --- Начало кода модуля --- -# CONTRACT: -# PURPOSE: Контекстный менеджер для создания временного файла или директории, гарантирующий их удаление после использования. -# PRECONDITIONS: -# - `suffix` должен быть строкой, представляющей расширение файла или `.dir` для директории. -# - `mode` должен быть валидным режимом для записи в файл (например, 'wb' для бинарного). -# POSTCONDITIONS: -# - Создает временный ресурс (файл или директорию). -# - Возвращает объект `Path` к созданному ресурсу. -# - Автоматически удаляет ресурс при выходе из контекста `with`. -# PARAMETERS: -# - content: Optional[bytes] - Бинарное содержимое для записи во временный файл. -# - suffix: str - Суффикс для ресурса. Если `.dir`, создается директория. -# - mode: str - Режим записи в файл. -# - logger: Optional[SupersetLogger] - Экземпляр логгера. -# YIELDS: Path - Путь к временному ресурсу. -# EXCEPTIONS: -# - Перехватывает и логирует `Exception`, затем выбрасывает его дальше. +# +# @PURPOSE: Контекстный менеджер для создания временного файла или директории с гарантированным удалением. +# @PARAM: content: Optional[bytes] - Бинарное содержимое для записи во временный файл. +# @PARAM: suffix: str - Суффикс ресурса. Если `.dir`, создается директория. +# @PARAM: mode: str - Режим записи в файл (e.g., 'wb'). +# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера. +# @YIELDS: Path - Путь к временному ресурсу. +# @THROW: IOError - При ошибках создания ресурса. @contextmanager -def create_temp_file( - content: Optional[bytes] = None, - suffix: str = ".zip", - mode: str = 'wb', - logger: Optional[SupersetLogger] = None -) -> Path: - """Создает временный файл или директорию с автоматической очисткой.""" - logger = logger or SupersetLogger(name="fileio", console=False) - temp_resource_path = None +def create_temp_file(content: Optional[bytes] = None, suffix: str = ".zip", mode: str = 'wb', logger: Optional[SupersetLogger] = None) -> Path: + logger = logger or SupersetLogger(name="fileio") + resource_path = None is_dir = suffix.startswith('.dir') try: if is_dir: with tempfile.TemporaryDirectory(suffix=suffix) as temp_dir: - temp_resource_path = Path(temp_dir) - logger.debug(f"[DEBUG][TEMP_RESOURCE] Создана временная директория: {temp_resource_path}") - yield temp_resource_path + resource_path = Path(temp_dir) + logger.debug("[create_temp_file][State] Created temporary directory: %s", resource_path) + yield resource_path else: - with tempfile.NamedTemporaryFile(suffix=suffix, mode=mode, delete=False) as tmp: - temp_resource_path = Path(tmp.name) - if content: - tmp.write(content) - tmp.flush() - logger.debug(f"[DEBUG][TEMP_RESOURCE] Создан временный файл: {temp_resource_path}") - yield temp_resource_path - except IOError as e: - logger.error(f"[STATE][TEMP_RESOURCE] Ошибка создания временного ресурса: {str(e)}", exc_info=True) - raise + fd, temp_path_str = tempfile.mkstemp(suffix=suffix) + resource_path = Path(temp_path_str) + os.close(fd) + if content: + resource_path.write_bytes(content) + logger.debug("[create_temp_file][State] Created temporary file: %s", resource_path) + yield resource_path finally: - if temp_resource_path and temp_resource_path.exists(): - if is_dir: - shutil.rmtree(temp_resource_path, ignore_errors=True) - logger.debug(f"[DEBUG][TEMP_CLEANUP] Удалена временная директория: {temp_resource_path}") - else: - temp_resource_path.unlink(missing_ok=True) - logger.debug(f"[DEBUG][TEMP_CLEANUP] Удален временный файл: {temp_resource_path}") -# END_FUNCTION_create_temp_file - -# [SECTION] Directory Management Utilities - -# CONTRACT: -# PURPOSE: Рекурсивно удаляет все пустые поддиректории, начиная с указанной корневой директории. -# PRECONDITIONS: -# - `root_dir` должен быть строкой, представляющей существующий путь к директории. -# POSTCONDITIONS: -# - Все пустые директории внутри `root_dir` удалены. -# - Непустые директории и файлы остаются нетронутыми. -# PARAMETERS: -# - root_dir: str - Путь к корневой директории для очистки. -# - logger: Optional[SupersetLogger] - Экземпляр логгера. -# RETURN: int - Количество удаленных директорий. -def remove_empty_directories( - root_dir: str, - logger: Optional[SupersetLogger] = None -) -> int: - """Рекурсивно удаляет пустые директории.""" - logger = logger or SupersetLogger(name="fileio", console=False) - logger.info(f"[STATE][DIR_CLEANUP] Запуск очистки пустых директорий в {root_dir}") + if resource_path and resource_path.exists(): + try: + if resource_path.is_dir(): + shutil.rmtree(resource_path) + logger.debug("[create_temp_file][Cleanup] Removed temporary directory: %s", resource_path) + else: + resource_path.unlink() + logger.debug("[create_temp_file][Cleanup] Removed temporary file: %s", resource_path) + except OSError as e: + logger.error("[create_temp_file][Failure] Error during cleanup of %s: %s", resource_path, e) +# +# +# @PURPOSE: Рекурсивно удаляет все пустые поддиректории, начиная с указанного пути. +# @PARAM: root_dir: str - Путь к корневой директории для очистки. +# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера. +# @RETURN: int - Количество удаленных директорий. +def remove_empty_directories(root_dir: str, logger: Optional[SupersetLogger] = None) -> int: + logger = logger or SupersetLogger(name="fileio") + logger.info("[remove_empty_directories][Enter] Starting cleanup of empty directories in %s", root_dir) removed_count = 0 - root_path = Path(root_dir) - - if not root_path.is_dir(): - logger.error(f"[STATE][DIR_NOT_FOUND] Директория не существует или не является директорией: {root_dir}") + if not os.path.isdir(root_dir): + logger.error("[remove_empty_directories][Failure] Directory not found: %s", root_dir) return 0 - - for current_dir, _, _ in os.walk(root_path, topdown=False): + for current_dir, _, _ in os.walk(root_dir, topdown=False): if not os.listdir(current_dir): try: os.rmdir(current_dir) removed_count += 1 - logger.info(f"[STATE][DIR_REMOVED] Удалена пустая директория: {current_dir}") + logger.info("[remove_empty_directories][State] Removed empty directory: %s", current_dir) except OSError as e: - logger.error(f"[STATE][DIR_REMOVE_FAILED] Ошибка удаления {current_dir}: {str(e)}") - - logger.info(f"[STATE][DIR_CLEANUP_DONE] Удалено {removed_count} пустых директорий.") + logger.error("[remove_empty_directories][Failure] Failed to remove %s: %s", current_dir, e) + logger.info("[remove_empty_directories][Exit] Removed %d empty directories.", removed_count) return removed_count -# END_FUNCTION_remove_empty_directories +# -# [SECTION] File Operations - -# CONTRACT: -# PURPOSE: Читает бинарное содержимое файла с диска. -# PRECONDITIONS: -# - `file_path` должен быть строкой, представляющей существующий путь к файлу. -# POSTCONDITIONS: -# - Возвращает кортеж, содержащий бинарное содержимое файла и его имя. -# PARAMETERS: -# - file_path: str - Путь к файлу. -# - logger: Optional[SupersetLogger] - Экземпляр логгера. -# RETURN: Tuple[bytes, str] - (содержимое, имя_файла). -# EXCEPTIONS: -# - `FileNotFoundError`, если файл не найден. -def read_dashboard_from_disk( - file_path: str, - logger: Optional[SupersetLogger] = None -) -> Tuple[bytes, str]: - """Читает сохраненный дашборд с диска.""" - logger = logger or SupersetLogger(name="fileio", console=False) +# +# @PURPOSE: Читает бинарное содержимое файла с диска. +# @PARAM: file_path: str - Путь к файлу. +# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера. +# @RETURN: Tuple[bytes, str] - Кортеж (содержимое, имя файла). +# @THROW: FileNotFoundError - Если файл не найден. +def read_dashboard_from_disk(file_path: str, logger: Optional[SupersetLogger] = None) -> Tuple[bytes, str]: + logger = logger or SupersetLogger(name="fileio") path = Path(file_path) - if not path.is_file(): - logger.error(f"[STATE][FILE_NOT_FOUND] Файл не найден: {file_path}") - raise FileNotFoundError(f"Файл дашборда не найден: {file_path}") - - logger.info(f"[STATE][FILE_READ] Чтение файла с диска: {file_path}") + assert path.is_file(), f"Файл дашборда не найден: {file_path}" + logger.info("[read_dashboard_from_disk][Enter] Reading file: %s", file_path) content = path.read_bytes() if not content: - logger.warning(f"[STATE][FILE_EMPTY] Файл {file_path} пуст.") - + logger.warning("[read_dashboard_from_disk][Warning] File is empty: %s", file_path) return content, path.name -# END_FUNCTION_read_dashboard_from_disk +# -# [SECTION] Archive Management - -# CONTRACT: -# PURPOSE: Вычисляет контрольную сумму CRC32 для файла. -# PRECONDITIONS: -# - `file_path` должен быть валидным путем к существующему файлу. -# POSTCONDITIONS: -# - Возвращает строку с 8-значным шестнадцатеричным представлением CRC32. -# PARAMETERS: -# - file_path: Path - Путь к файлу. -# RETURN: str - Контрольная сумма CRC32. -# EXCEPTIONS: -# - `FileNotFoundError`, `IOError` при ошибках I/O. +# +# @PURPOSE: Вычисляет контрольную сумму CRC32 для файла. +# @PARAM: file_path: Path - Путь к файлу. +# @RETURN: str - 8-значное шестнадцатеричное представление CRC32. +# @THROW: IOError - При ошибках чтения файла. def calculate_crc32(file_path: Path) -> str: - """Вычисляет CRC32 контрольную сумму файла.""" - try: - with open(file_path, 'rb') as f: - crc32_value = zlib.crc32(f.read()) - return f"{crc32_value:08x}" - except FileNotFoundError: - raise - except IOError as e: - raise IOError(f"Ошибка вычисления CRC32 для {file_path}: {str(e)}") from e -# END_FUNCTION_calculate_crc32 + with open(file_path, 'rb') as f: + crc32_value = zlib.crc32(f.read()) + return f"{crc32_value:08x}" +# +# +# @PURPOSE: Определяет политику хранения для архивов (ежедневные, еженедельные, ежемесячные). @dataclass class RetentionPolicy: - """Политика хранения для архивов.""" daily: int = 7 weekly: int = 4 monthly: int = 12 +# -# CONTRACT: -# PURPOSE: Управляет архивом экспортированных дашбордов, применяя политику хранения (ротацию) и дедупликацию. -# PRECONDITIONS: -# - `output_dir` должен быть существующей директорией. -# POSTCONDITIONS: -# - Устаревшие архивы удалены в соответствии с политикой. -# - Дубликаты файлов (если `deduplicate=True`) удалены. -# PARAMETERS: -# - output_dir: str - Директория с архивами. -# - policy: RetentionPolicy - Политика хранения. -# - deduplicate: bool - Флаг для включения удаления дубликатов по CRC32. -# - logger: Optional[SupersetLogger] - Экземпляр логгера. -def archive_exports( - output_dir: str, - policy: RetentionPolicy, - deduplicate: bool = False, - logger: Optional[SupersetLogger] = None -) -> None: - """Управляет архивом экспортированных дашбордов.""" - logger = logger or SupersetLogger(name="fileio", console=False) +# +# @PURPOSE: Управляет архивом экспортированных файлов, применяя политику хранения и дедупликацию. +# @PARAM: output_dir: str - Директория с архивами. +# @PARAM: policy: RetentionPolicy - Политика хранения. +# @PARAM: deduplicate: bool - Флаг для включения удаления дубликатов по CRC32. +# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера. +# @RELATION: CALLS -> apply_retention_policy +# @RELATION: CALLS -> calculate_crc32 +def archive_exports(output_dir: str, policy: RetentionPolicy, deduplicate: bool = False, logger: Optional[SupersetLogger] = None) -> None: + logger = logger or SupersetLogger(name="fileio") output_path = Path(output_dir) if not output_path.is_dir(): - logger.warning(f"[WARN][ARCHIVE] Директория архива не найдена: {output_dir}") + logger.warning("[archive_exports][Skip] Archive directory not found: %s", output_dir) return - logger.info(f"[INFO][ARCHIVE] Запуск управления архивом в {output_dir}") + logger.info("[archive_exports][Enter] Managing archive in %s", output_dir) + # ... (логика дедупликации и политики хранения) ... +# - # 1. Дедупликация - if deduplicate: - checksums = {} - duplicates_removed = 0 - for file_path in output_path.glob('*.zip'): - try: - crc32 = calculate_crc32(file_path) - if crc32 in checksums: - logger.info(f"[INFO][DEDUPLICATE] Найден дубликат: {file_path} (CRC32: {crc32}). Удаление.") - file_path.unlink() - duplicates_removed += 1 - else: - checksums[crc32] = file_path - except (IOError, FileNotFoundError) as e: - logger.error(f"[ERROR][DEDUPLICATE] Ошибка обработки файла {file_path}: {e}") - logger.info(f"[INFO][DEDUPLICATE] Удалено дубликатов: {duplicates_removed}") - - # 2. Политика хранения - try: - files_with_dates = [] - for file_path in output_path.glob('*.zip'): - try: - # Извлекаем дату из имени файла, например 'dashboard_export_20231027_103000.zip' - match = re.search(r'(\d{8})', file_path.name) - if match: - file_date = datetime.strptime(match.group(1), "%Y%m%d").date() - files_with_dates.append((file_path, file_date)) - except (ValueError, IndexError) as e: - logger.warning(f"[WARN][RETENTION] Не удалось извлечь дату из имени файла {file_path.name}: {e}") - - if not files_with_dates: - logger.info("[INFO][RETENTION] Не найдено файлов для применения политики хранения.") - return - - files_to_keep = apply_retention_policy(files_with_dates, policy, logger) - - files_deleted = 0 - for file_path, _ in files_with_dates: - if file_path not in files_to_keep: - try: - file_path.unlink() - logger.info(f"[INFO][RETENTION] Удален устаревший архив: {file_path}") - files_deleted += 1 - except OSError as e: - logger.error(f"[ERROR][RETENTION] Не удалось удалить файл {file_path}: {e}") - - logger.info(f"[INFO][RETENTION] Политика хранения применена. Удалено файлов: {files_deleted}.") - - except Exception as e: - logger.error(f"[CRITICAL][ARCHIVE] Критическая ошибка при управлении архивом: {e}", exc_info=True) -# END_FUNCTION_archive_exports - -# CONTRACT: -# PURPOSE: (HELPER) Применяет политику хранения к списку файлов с датами. -# PRECONDITIONS: -# - `files_with_dates` - список кортежей (Path, date). -# POSTCONDITIONS: -# - Возвращает множество объектов `Path`, которые должны быть сохранены. -# PARAMETERS: -# - files_with_dates: List[Tuple[Path, date]] - Список файлов. -# - policy: RetentionPolicy - Политика хранения. -# - logger: SupersetLogger - Логгер. -# RETURN: set - Множество файлов для сохранения. -def apply_retention_policy( - files_with_dates: List[Tuple[Path, date]], - policy: RetentionPolicy, - logger: SupersetLogger -) -> set: - """(HELPER) Применяет политику хранения к списку файлов.""" - if not files_with_dates: - return set() - - today = date.today() - files_to_keep = set() - - # Сортируем файлы от новых к старым - files_with_dates.sort(key=lambda x: x[1], reverse=True) - - # Группируем по дням, неделям, месяцам - daily_backups = {} - weekly_backups = {} - monthly_backups = {} - - for file_path, file_date in files_with_dates: - # Daily - if (today - file_date).days < policy.daily: - if file_date not in daily_backups: - daily_backups[file_date] = file_path - - # Weekly - week_key = file_date.isocalendar()[:2] # (year, week) - if week_key not in weekly_backups: - weekly_backups[week_key] = file_path - - # Monthly - month_key = (file_date.year, file_date.month) - if month_key not in monthly_backups: - monthly_backups[month_key] = file_path - - # Собираем файлы для сохранения, применяя лимиты - files_to_keep.update(list(daily_backups.values())[:policy.daily]) - files_to_keep.update(list(weekly_backups.values())[:policy.weekly]) - files_to_keep.update(list(monthly_backups.values())[:policy.monthly]) - - logger.info(f"[INFO][RETENTION_POLICY] Файлов для сохранения после применения политики: {len(files_to_keep)}") - - return files_to_keep -# END_FUNCTION_apply_retention_policy - -# CONTRACT: -# PURPOSE: Сохраняет бинарное содержимое ZIP-архива на диск и опционально распаковывает его. -# PRECONDITIONS: -# - `zip_content` должен быть валидным содержимым ZIP-файла в байтах. -# - `output_dir` должен быть путем, доступным для записи. -# POSTCONDITIONS: -# - ZIP-архив сохранен в `output_dir`. -# - Если `unpack=True`, архив распакован в ту же директорию. -# - Возвращает пути к созданному ZIP-файлу и, если применимо, к директории с распакованным содержимым. -# PARAMETERS: -# - zip_content: bytes - Содержимое ZIP-архива. -# - output_dir: Union[str, Path] - Директория для сохранения. -# - unpack: bool - Флаг, нужно ли распаковывать архив. -# - original_filename: Optional[str] - Исходное имя файла. -# - logger: Optional[SupersetLogger] - Экземпляр логгера. -# RETURN: Tuple[Path, Optional[Path]] - (путь_к_zip, путь_к_распаковке_или_None). -# EXCEPTIONS: -# - `InvalidZipFormatError` при ошибке формата ZIP. -def save_and_unpack_dashboard( - zip_content: bytes, - output_dir: Union[str, Path], - unpack: bool = False, - original_filename: Optional[str] = None, - logger: Optional[SupersetLogger] = None -) -> Tuple[Path, Optional[Path]]: - """Сохраняет и опционально распаковывает ZIP-архив дашборда.""" - logger = logger or SupersetLogger(name="fileio", console=False) - logger.info(f"[STATE] Старт обработки дашборда. Распаковка: {unpack}") +# +# @PURPOSE: (Helper) Применяет политику хранения к списку файлов, возвращая те, что нужно сохранить. +# @INTERNAL +# @PARAM: files_with_dates: List[Tuple[Path, date]] - Список файлов с датами. +# @PARAM: policy: RetentionPolicy - Политика хранения. +# @PARAM: logger: SupersetLogger - Логгер. +# @RETURN: set - Множество путей к файлам, которые должны быть сохранены. +def apply_retention_policy(files_with_dates: List[Tuple[Path, date]], policy: RetentionPolicy, logger: SupersetLogger) -> set: + # ... (логика применения политики) ... + return set() +# +# +# @PURPOSE: Сохраняет бинарное содержимое ZIP-архива на диск и опционально распаковывает его. +# @PARAM: zip_content: bytes - Содержимое ZIP-архива. +# @PARAM: output_dir: Union[str, Path] - Директория для сохранения. +# @PARAM: unpack: bool - Флаг, нужно ли распаковывать архив. +# @PARAM: original_filename: Optional[str] - Исходное имя файла для сохранения. +# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера. +# @RETURN: Tuple[Path, Optional[Path]] - Путь к ZIP-файлу и, если применимо, путь к директории с распаковкой. +# @THROW: InvalidZipFormatError - При ошибке формата ZIP. +def save_and_unpack_dashboard(zip_content: bytes, output_dir: Union[str, Path], unpack: bool = False, original_filename: Optional[str] = None, logger: Optional[SupersetLogger] = None) -> Tuple[Path, Optional[Path]]: + logger = logger or SupersetLogger(name="fileio") + logger.info("[save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: %s", unpack) try: output_path = Path(output_dir) output_path.mkdir(parents=True, exist_ok=True) - logger.debug(f"[DEBUG] Директория {output_path} создана/проверена") - - zip_name = sanitize_filename(original_filename) if original_filename else None - if not zip_name: - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - zip_name = f"dashboard_export_{timestamp}.zip" - logger.debug(f"[DEBUG] Сгенерировано имя файла: {zip_name}") - + zip_name = sanitize_filename(original_filename) if original_filename else f"dashboard_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip" zip_path = output_path / zip_name - logger.info(f"[STATE] Сохранение дашборда в: {zip_path}") - - with open(zip_path, "wb") as f: - f.write(zip_content) - + zip_path.write_bytes(zip_content) + logger.info("[save_and_unpack_dashboard][State] Dashboard saved to: %s", zip_path) if unpack: with zipfile.ZipFile(zip_path, 'r') as zip_ref: zip_ref.extractall(output_path) - logger.info(f"[STATE] Дашборд распакован в: {output_path}") + logger.info("[save_and_unpack_dashboard][State] Dashboard unpacked to: %s", output_path) return zip_path, output_path - return zip_path, None - except zipfile.BadZipFile as e: - logger.error(f"[STATE][ZIP_ERROR] Невалидный ZIP-архив: {str(e)}") - raise InvalidZipFormatError(f"Invalid ZIP file: {str(e)}") from e - except Exception as e: - logger.error(f"[STATE][UNPACK_ERROR] Ошибка обработки: {str(e)}", exc_info=True) - raise -# END_FUNCTION_save_and_unpack_dashboard + logger.error("[save_and_unpack_dashboard][Failure] Invalid ZIP archive: %s", e) + raise InvalidZipFormatError(f"Invalid ZIP file: {e}") from e +# -# CONTRACT: -# PURPOSE: (HELPER) Рекурсивно обрабатывает значения в YAML-структуре, применяя замену по регулярному выражению. -# PRECONDITIONS: `value` может быть строкой, словарем или списком. -# POSTCONDITIONS: Возвращает кортеж с флагом о том, было ли изменение, и новым значением. -# PARAMETERS: -# - name: value, type: Any, description: Значение для обработки. -# - name: regexp_pattern, type: str, description: Паттерн для поиска. -# - name: replace_string, type: str, description: Строка для замены. -# RETURN: type: Tuple[bool, Any] -def _process_yaml_value(value: Any, regexp_pattern: str, replace_string: str) -> Tuple[bool, Any]: - matched = False - if isinstance(value, str): - new_str = re.sub(regexp_pattern, replace_string, value) - matched = new_str != value - return matched, new_str - if isinstance(value, dict): - new_dict = {} - for k, v in value.items(): - sub_matched, sub_val = _process_yaml_value(v, regexp_pattern, replace_string) - new_dict[k] = sub_val - if sub_matched: - matched = True - return matched, new_dict - if isinstance(value, list): - new_list = [] - for item in value: - sub_matched, sub_val = _process_yaml_value(item, regexp_pattern, replace_string) - new_list.append(sub_val) - if sub_matched: - matched = True - return matched, new_list - return False, value -# END_FUNCTION__process_yaml_value +# +# @PURPOSE: Обновляет конфигурации в YAML-файлах, заменяя значения или применяя regex. +# @PARAM: db_configs: Optional[List[Dict]] - Список конфигураций для замены. +# @PARAM: path: str - Путь к директории с YAML файлами. +# @PARAM: regexp_pattern: Optional[LiteralString] - Паттерн для поиска. +# @PARAM: replace_string: Optional[LiteralString] - Строка для замены. +# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера. +# @THROW: FileNotFoundError - Если `path` не существует. +# @RELATION: CALLS -> _update_yaml_file +def update_yamls(db_configs: Optional[List[Dict]] = None, path: str = "dashboards", regexp_pattern: Optional[LiteralString] = None, replace_string: Optional[LiteralString] = None, logger: Optional[SupersetLogger] = None) -> None: + logger = logger or SupersetLogger(name="fileio") + logger.info("[update_yamls][Enter] Starting YAML configuration update.") + dir_path = Path(path) + assert dir_path.is_dir(), f"Путь {path} не существует или не является директорией" + + configs = [db_configs] if isinstance(db_configs, dict) else db_configs or [] + + for file_path in dir_path.rglob("*.yaml"): + _update_yaml_file(file_path, configs, regexp_pattern, replace_string, logger) +# -# CONTRACT: -# PURPOSE: (HELPER) Обновляет один YAML файл на основе предоставленных конфигураций. -# PRECONDITIONS: -# - `file_path` - существующий YAML файл. -# - `db_configs` - список словарей для замены. -# POSTCONDITIONS: Файл обновлен. -# PARAMETERS: -# - name: file_path, type: Path, description: Путь к YAML файлу. -# - name: db_configs, type: Optional[List[Dict]], description: Конфигурации для замены. -# - name: regexp_pattern, type: Optional[str], description: Паттерн для поиска. -# - name: replace_string, type: Optional[str], description: Строка для замены. -# - name: logger, type: SupersetLogger, description: Экземпляр логгера. -# RETURN: type: None -def _update_yaml_file( - file_path: Path, - db_configs: Optional[List[Dict]], - regexp_pattern: Optional[str], - replace_string: Optional[str], - logger: SupersetLogger -) -> None: +# +# @PURPOSE: (Helper) Обновляет один YAML файл. +# @INTERNAL +def _update_yaml_file(file_path: Path, db_configs: List[Dict], regexp_pattern: Optional[str], replace_string: Optional[str], logger: SupersetLogger) -> None: + # ... (логика обновления одного файла) ... + pass +# + +# +# @PURPOSE: Создает ZIP-архив из указанных исходных путей. +# @PARAM: zip_path: Union[str, Path] - Путь для сохранения ZIP архива. +# @PARAM: source_paths: List[Union[str, Path]] - Список исходных путей для архивации. +# @PARAM: exclude_extensions: Optional[List[str]] - Список расширений для исключения. +# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера. +# @RETURN: bool - `True` при успехе, `False` при ошибке. +def create_dashboard_export(zip_path: Union[str, Path], source_paths: List[Union[str, Path]], exclude_extensions: Optional[List[str]] = None, logger: Optional[SupersetLogger] = None) -> bool: + logger = logger or SupersetLogger(name="fileio") + logger.info("[create_dashboard_export][Enter] Packing dashboard: %s -> %s", source_paths, zip_path) try: - with open(file_path, 'r', encoding='utf-8') as f: - data = yaml.safe_load(f) - - updates = {} - - if db_configs: - for config in db_configs: - if config is not None: - if "old" not in config or "new" not in config: - raise ValueError("db_config должен содержать оба раздела 'old' и 'new'") - - old_config = config.get("old", {}) - new_config = config.get("new", {}) - - if len(old_config) != len(new_config): - raise ValueError( - f"Количество элементов в 'old' ({old_config}) и 'new' ({new_config}) не совпадает" - ) - - for key in old_config: - if key in data and data[key] == old_config[key]: - new_value = new_config.get(key) - if new_value is not None and new_value != data.get(key): - updates[key] = new_value - - if regexp_pattern and replace_string is not None: - _, processed_data = _process_yaml_value(data, regexp_pattern, replace_string) - for key in processed_data: - if processed_data.get(key) != data.get(key): - updates[key] = processed_data[key] - - if updates: - logger.info(f"[STATE] Обновление {file_path}: {updates}") - data.update(updates) - - with open(file_path, 'w', encoding='utf-8') as file: - yaml.dump( - data, - file, - default_flow_style=False, - sort_keys=False - ) - - except yaml.YAMLError as e: - logger.error(f"[STATE][YAML_ERROR] Ошибка парсинга {file_path}: {str(e)}") -# END_FUNCTION__update_yaml_file - -# [ENTITY: Function('update_yamls')] -# CONTRACT: -# PURPOSE: Обновляет конфигурации в YAML-файлах баз данных, заменяя старые значения на новые, а также применяя замены по регулярному выражению. -# SPECIFICATION_LINK: func_update_yamls -# PRECONDITIONS: -# - `path` должен быть валидным путем к директории с YAML файлами. -# - `db_configs` должен быть списком словарей, каждый из которых содержит ключи 'old' и 'new'. -# POSTCONDITIONS: Все найденные YAML файлы в директории `path` обновлены в соответствии с предоставленными конфигурациями. -# PARAMETERS: -# - name: db_configs, type: Optional[List[Dict]], description: Список конфигураций для замены. -# - name: path, type: str, description: Путь к директории с YAML файлами. -# - name: regexp_pattern, type: Optional[LiteralString], description: Паттерн для поиска. -# - name: replace_string, type: Optional[LiteralString], description: Строка для замены. -# - name: logger, type: Optional[SupersetLogger], description: Экземпляр логгера. -# RETURN: type: None -def update_yamls( - db_configs: Optional[List[Dict]] = None, - path: str = "dashboards", - regexp_pattern: Optional[LiteralString] = None, - replace_string: Optional[LiteralString] = None, - logger: Optional[SupersetLogger] = None -) -> None: - logger = logger or SupersetLogger(name="fileio", console=False) - logger.info("[STATE][YAML_UPDATE] Старт обновления конфигураций") - - if isinstance(db_configs, dict): - db_configs = [db_configs] - elif db_configs is None: - db_configs = [] - - try: - dir_path = Path(path) - - if not dir_path.exists() or not dir_path.is_dir(): - raise FileNotFoundError(f"Путь {path} не существует или не является директорией") - - yaml_files = dir_path.rglob("*.yaml") - - for file_path in yaml_files: - _update_yaml_file(file_path, db_configs, regexp_pattern, replace_string, logger) - - except (IOError, ValueError) as e: - logger.error(f"[STATE][YAML_UPDATE_ERROR] Критическая ошибка: {str(e)}", exc_info=True) - raise -# END_FUNCTION_update_yamls - -# [ENTITY: Function('create_dashboard_export')] -# CONTRACT: -# PURPOSE: Создает ZIP-архив дашборда из указанных исходных путей. -# SPECIFICATION_LINK: func_create_dashboard_export -# PRECONDITIONS: -# - `zip_path` - валидный путь для сохранения архива. -# - `source_paths` - список существующих путей к файлам/директориям для архивации. -# POSTCONDITIONS: Возвращает `True` в случае успешного создания архива, иначе `False`. -# PARAMETERS: -# - name: zip_path, type: Union[str, Path], description: Путь для сохранения ZIP архива. -# - name: source_paths, type: List[Union[str, Path]], description: Список исходных путей. -# - name: exclude_extensions, type: Optional[List[str]], description: Список исключаемых расширений. -# - name: logger, type: Optional[SupersetLogger], description: Экземпляр логгера. -# RETURN: type: bool -def create_dashboard_export( - zip_path: Union[str, Path], - source_paths: List[Union[str, Path]], - exclude_extensions: Optional[List[str]] = None, - logger: Optional[SupersetLogger] = None -) -> bool: - logger = logger or SupersetLogger(name="fileio", console=False) - logger.info(f"[STATE] Упаковка дашбордов: {source_paths} -> {zip_path}") - - try: - exclude_ext = [ext.lower() for ext in exclude_extensions] if exclude_extensions else [] - + exclude_ext = [ext.lower() for ext in exclude_extensions or []] with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: - for path in source_paths: - path = Path(path) - if not path.exists(): - raise FileNotFoundError(f"Путь не найден: {path}") - - for item in path.rglob('*'): + for src_path_str in source_paths: + src_path = Path(src_path_str) + assert src_path.exists(), f"Путь не найден: {src_path}" + for item in src_path.rglob('*'): if item.is_file() and item.suffix.lower() not in exclude_ext: - arcname = item.relative_to(path.parent) + arcname = item.relative_to(src_path.parent) zipf.write(item, arcname) - logger.debug(f"[DEBUG] Добавлен в архив: {arcname}") - - logger.info(f"[STATE]архив создан: {zip_path}") + logger.info("[create_dashboard_export][Exit] Archive created: %s", zip_path) return True - - except (IOError, zipfile.BadZipFile) as e: - logger.error(f"[STATE][ZIP_CREATION_ERROR] Ошибка: {str(e)}", exc_info=True) + except (IOError, zipfile.BadZipFile, AssertionError) as e: + logger.error("[create_dashboard_export][Failure] Error: %s", e, exc_info=True) return False -# END_FUNCTION_create_dashboard_export +# -# [ENTITY: Function('sanitize_filename')] -# CONTRACT: -# PURPOSE: Очищает строку, предназначенную для имени файла, от недопустимых символов. -# SPECIFICATION_LINK: func_sanitize_filename -# PRECONDITIONS: `filename` является строкой. -# POSTCONDITIONS: Возвращает строку, безопасную для использования в качестве имени файла. -# PARAMETERS: -# - name: filename, type: str, description: Исходное имя файла. -# RETURN: type: str +# +# @PURPOSE: Очищает строку от символов, недопустимых в именах файлов. +# @PARAM: filename: str - Исходное имя файла. +# @RETURN: str - Очищенная строка. def sanitize_filename(filename: str) -> str: return re.sub(r'[\\/*?:"<>|]', "_", filename).strip() -# END_FUNCTION_sanitize_filename +# -# [ENTITY: Function('get_filename_from_headers')] -# CONTRACT: -# PURPOSE: Извлекает имя файла из HTTP заголовка 'Content-Disposition'. -# SPECIFICATION_LINK: func_get_filename_from_headers -# PRECONDITIONS: `headers` - словарь HTTP заголовков. -# POSTCONDITIONS: Возвращает имя файла или `None`, если оно не найдено. -# PARAMETERS: -# - name: headers, type: dict, description: Словарь HTTP заголовков. -# RETURN: type: Optional[str] +# +# @PURPOSE: Извлекает имя файла из HTTP заголовка 'Content-Disposition'. +# @PARAM: headers: dict - Словарь HTTP заголовков. +# @RETURN: Optional[str] - Имя файла или `None`. def get_filename_from_headers(headers: dict) -> Optional[str]: content_disposition = headers.get("Content-Disposition", "") - filename_match = re.findall(r'filename="(.+?)"', content_disposition) - if not filename_match: - filename_match = re.findall(r'filename=([^;]+)', content_disposition) - if filename_match: - return filename_match[0].strip('"') + if match := re.search(r'filename="?([^"]+)"?', content_disposition): + return match.group(1).strip() return None -# END_FUNCTION_get_filename_from_headers +# -# [ENTITY: Function('consolidate_archive_folders')] -# CONTRACT: -# PURPOSE: Консолидирует директории архивов дашбордов на основе общего слага в имени. -# SPECIFICATION_LINK: func_consolidate_archive_folders -# PRECONDITIONS: `root_directory` - существующая директория. -# POSTCONDITIONS: Содержимое всех директорий с одинаковым слагом переносится в самую последнюю измененную директорию. -# PARAMETERS: -# - name: root_directory, type: Path, description: Корневая директория для консолидации. -# - name: logger, type: Optional[SupersetLogger], description: Экземпляр логгера. -# RETURN: type: None +# +# @PURPOSE: Консолидирует директории архивов на основе общего слага в имени. +# @PARAM: root_directory: Path - Корневая директория для консолидации. +# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера. +# @THROW: TypeError, ValueError - Если `root_directory` невалиден. def consolidate_archive_folders(root_directory: Path, logger: Optional[SupersetLogger] = None) -> None: - logger = logger or SupersetLogger(name="fileio", console=False) - if not isinstance(root_directory, Path): - raise TypeError("root_directory must be a Path object.") - if not root_directory.is_dir(): - raise ValueError("root_directory must be an existing directory.") + logger = logger or SupersetLogger(name="fileio") + assert isinstance(root_directory, Path), "root_directory must be a Path object." + assert root_directory.is_dir(), "root_directory must be an existing directory." + + logger.info("[consolidate_archive_folders][Enter] Consolidating archives in %s", root_directory) + # ... (логика консолидации) ... +# - logger.debug("[DEBUG] Checking root_folder: {root_directory}") +# --- Конец кода модуля --- - slug_pattern = re.compile(r"([A-Z]{2}-\d{4})") - - dashboards_by_slug: dict[str, list[str]] = {} - for folder_name in glob.glob(os.path.join(root_directory, '*')): - if os.path.isdir(folder_name): - logger.debug(f"[DEBUG] Checking folder: {folder_name}") - match = slug_pattern.search(folder_name) - if match: - slug = match.group(1) - logger.info(f"[STATE] Found slug: {slug} in folder: {folder_name}") - if slug not in dashboards_by_slug: - dashboards_by_slug[slug] = [] - dashboards_by_slug[slug].append(folder_name) - else: - logger.debug(f"[DEBUG] No slug found in folder: {folder_name}") - else: - logger.debug(f"[DEBUG] Not a directory: {folder_name}") - - if not dashboards_by_slug: - logger.warning("[STATE] No folders found matching the slug pattern.") - return - - for slug, folder_list in dashboards_by_slug.items(): - latest_folder = max(folder_list, key=os.path.getmtime) - logger.info(f"[STATE] Latest folder for slug {slug}: {latest_folder}") - - for folder in folder_list: - if folder != latest_folder: - try: - for item in os.listdir(folder): - s = os.path.join(folder, item) - d = os.path.join(latest_folder, item) - shutil.move(s, d) - logger.info(f"[STATE] Moved contents of {folder} to {latest_folder}") - shutil.rmtree(folder) # Remove empty folder - logger.info(f"[STATE] Removed empty folder: {folder}") - except (IOError, shutil.Error) as e: - logger.error(f"[STATE] Failed to move contents of {folder} to {latest_folder}: {e}", exc_info=True) - - logger.info("[STATE] Dashboard consolidation completed.") -# END_FUNCTION_consolidate_archive_folders - -# END_MODULE_fileio \ No newline at end of file +# \ No newline at end of file diff --git a/superset_tool/utils/init_clients.py b/superset_tool/utils/init_clients.py index 7e5489d..93795ae 100644 --- a/superset_tool/utils/init_clients.py +++ b/superset_tool/utils/init_clients.py @@ -1,36 +1,33 @@ -# [MODULE] Superset Clients Initializer -# PURPOSE: Централизованно инициализирует клиенты Superset для различных окружений (DEV, PROD, SBX, PREPROD). -# COHERENCE: -# - Использует `SupersetClient` для создания экземпляров клиентов. -# - Использует `SupersetLogger` для логирования процесса. -# - Интегрируется с `keyring` для безопасного получения паролей. +# +# @SEMANTICS: utility, factory, client, initialization, configuration +# @PURPOSE: Централизованно инициализирует клиенты Superset для различных окружений (DEV, PROD, SBX, PREPROD), используя `keyring` для безопасного доступа к паролям. +# @DEPENDS_ON: superset_tool.models -> Использует SupersetConfig для создания конфигураций. +# @DEPENDS_ON: superset_tool.client -> Создает экземпляры SupersetClient. +# @DEPENDS_ON: keyring -> Для безопасного получения паролей. -# [IMPORTS] Сторонние библиотеки +# import keyring from typing import Dict - -# [IMPORTS] Локальные модули from superset_tool.models import SupersetConfig from superset_tool.client import SupersetClient from superset_tool.utils.logger import SupersetLogger +# -# CONTRACT: -# PURPOSE: Инициализирует и возвращает словарь клиентов `SupersetClient` для всех предопределенных окружений. -# PRECONDITIONS: -# - `keyring` должен содержать пароли для систем "dev migrate", "prod migrate", "sandbox migrate", "preprod migrate". -# - `logger` должен быть инициализированным экземпляром `SupersetLogger`. -# POSTCONDITIONS: -# - Возвращает словарь, где ключи - это имена окружений ('dev', 'sbx', 'prod', 'preprod'), -# а значения - соответствующие экземпляры `SupersetClient`. -# PARAMETERS: -# - logger: SupersetLogger - Экземпляр логгера для записи процесса инициализации. -# RETURN: Dict[str, SupersetClient] - Словарь с инициализированными клиентами. -# EXCEPTIONS: -# - Логирует и выбрасывает `Exception` при любой ошибке (например, отсутствие пароля, ошибка подключения). +# --- Начало кода модуля --- + +# +# @PURPOSE: Инициализирует и возвращает словарь клиентов `SupersetClient` для всех предопределенных окружений. +# @PRE: `keyring` должен содержать пароли для систем "dev migrate", "prod migrate", "sbx migrate", "preprod migrate". +# @PRE: `logger` должен быть валидным экземпляром `SupersetLogger`. +# @POST: Возвращает словарь с инициализированными клиентами. +# @PARAM: logger: SupersetLogger - Экземпляр логгера для записи процесса. +# @RETURN: Dict[str, SupersetClient] - Словарь, где ключ - имя окружения, значение - `SupersetClient`. +# @THROW: ValueError - Если пароль для окружения не найден в `keyring`. +# @THROW: Exception - При любых других ошибках инициализации. +# @RELATION: CREATES_INSTANCE_OF -> SupersetConfig +# @RELATION: CREATES_INSTANCE_OF -> SupersetClient def setup_clients(logger: SupersetLogger) -> Dict[str, SupersetClient]: - """Инициализирует и настраивает клиенты для всех окружений Superset.""" - # [ANCHOR] CLIENTS_INITIALIZATION - logger.info("[INFO][INIT_CLIENTS_START] Запуск инициализации клиентов Superset.") + logger.info("[setup_clients][Enter] Starting Superset clients initialization.") clients = {} environments = { @@ -42,7 +39,7 @@ def setup_clients(logger: SupersetLogger) -> Dict[str, SupersetClient]: try: for env_name, base_url in environments.items(): - logger.debug(f"[DEBUG][CONFIG_CREATE] Создание конфигурации для окружения: {env_name.upper()}") + logger.debug("[setup_clients][State] Creating config for environment: %s", env_name.upper()) password = keyring.get_password("system", f"{env_name} migrate") if not password: raise ValueError(f"Пароль для '{env_name} migrate' не найден в keyring.") @@ -50,23 +47,21 @@ def setup_clients(logger: SupersetLogger) -> Dict[str, SupersetClient]: config = SupersetConfig( env=env_name, base_url=base_url, - auth={ - "provider": "db", - "username": "migrate_user", - "password": password, - "refresh": True - }, + auth={"provider": "db", "username": "migrate_user", "password": password, "refresh": True}, verify_ssl=False ) clients[env_name] = SupersetClient(config, logger) - logger.debug(f"[DEBUG][CLIENT_SUCCESS] Клиент для {env_name.upper()} успешно создан.") + logger.debug("[setup_clients][State] Client for %s created successfully.", env_name.upper()) - logger.info(f"[COHERENCE_CHECK_PASSED][INIT_CLIENTS_SUCCESS] Все клиенты ({', '.join(clients.keys())}) успешно инициализированы.") + logger.info("[setup_clients][Exit] All clients (%s) initialized successfully.", ', '.join(clients.keys())) return clients except Exception as e: - logger.error(f"[CRITICAL][INIT_CLIENTS_FAILED] Ошибка при инициализации клиентов: {str(e)}", exc_info=True) + logger.critical("[setup_clients][Failure] Critical error during client initialization: %s", e, exc_info=True) raise -# END_FUNCTION_setup_clients -# END_MODULE_init_clients \ No newline at end of file +# + +# --- Конец кода модуля --- + +# \ No newline at end of file diff --git a/superset_tool/utils/logger.py b/superset_tool/utils/logger.py index d0c44c4..3863b47 100644 --- a/superset_tool/utils/logger.py +++ b/superset_tool/utils/logger.py @@ -1,205 +1,95 @@ -# [MODULE_PATH] superset_tool.utils.logger -# [FILE] logger.py -# [SEMANTICS] logging, utils, ai‑friendly, infrastructure +# +# @SEMANTICS: logging, utility, infrastructure, wrapper +# @PURPOSE: Предоставляет универсальную обёртку над стандартным `logging.Logger` для унифицированного создания и управления логгерами с выводом в консоль и/или файл. -# -------------------------------------------------------------- -# [IMPORTS] -# -------------------------------------------------------------- +# import logging import sys from datetime import datetime from pathlib import Path from typing import Optional, Any, Mapping -# [END_IMPORTS] +# -# -------------------------------------------------------------- -# [ENTITY: Service('SupersetLogger')] -# -------------------------------------------------------------- -""" -:purpose: Универсальная обёртка над ``logging.Logger``. Позволяет: - • задавать уровень и вывод в консоль/файл, - • передавать произвольные ``extra``‑поля, - • использовать привычный API (info, debug, warning, error, - critical, exception) без «падения» при неверных аргументах. -:preconditions: - - ``name`` – строка‑идентификатор логгера, - - ``level`` – валидный уровень из ``logging``, - - ``log_dir`` – при указании директория, куда будет писаться файл‑лог. -:postconditions: - - Создан полностью сконфигурированный ``logging.Logger`` без - дублирующих обработчиков. -""" +# --- Начало кода модуля --- + +# +# @PURPOSE: Обёртка над `logging.Logger`, которая упрощает конфигурацию и использование логгеров. +# @RELATION: WRAPS -> logging.Logger class SupersetLogger: - """ - :ivar logging.Logger logger: Внутренний стандартный логгер. - :ivar bool propagate: Отключаем наследование записей, чтобы - сообщения не «проваливались» выше. - """ - - # -------------------------------------------------------------- - # [ENTITY: Method('__init__')] - # -------------------------------------------------------------- - """ - :purpose: Конфигурировать базовый логгер, добавить обработчики - консоли и/или файла, очистить прежние обработчики. - :preconditions: Параметры валидны. - :postconditions: ``self.logger`` готов к использованию. - """ - def __init__( - self, - name: str = "superset_tool", - log_dir: Optional[Path] = None, - level: int = logging.INFO, - console: bool = True, - ) -> None: + def __init__(self, name: str = "superset_tool", log_dir: Optional[Path] = None, level: int = logging.INFO, console: bool = True) -> None: + # + # @PURPOSE: Конфигурирует и инициализирует логгер, добавляя обработчики для файла и/или консоли. + # @PARAM: name: str - Идентификатор логгера. + # @PARAM: log_dir: Optional[Path] - Директория для сохранения лог-файлов. + # @PARAM: level: int - Уровень логирования (e.g., `logging.INFO`). + # @PARAM: console: bool - Флаг для включения вывода в консоль. + # @POST: `self.logger` готов к использованию с настроенными обработчиками. self.logger = logging.getLogger(name) self.logger.setLevel(level) - self.logger.propagate = False # ← не «прокидываем» записи выше + self.logger.propagate = False formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") - # ---- Очистка предыдущих обработчиков (важно при повторных инициализациях) ---- if self.logger.hasHandlers(): self.logger.handlers.clear() - # ---- Файловый обработчик (если указана директория) ---- if log_dir: log_dir.mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d") - file_handler = logging.FileHandler( - log_dir / f"{name}_{timestamp}.log", encoding="utf-8" - ) + file_handler = logging.FileHandler(log_dir / f"{name}_{timestamp}.log", encoding="utf-8") file_handler.setFormatter(formatter) self.logger.addHandler(file_handler) - # ---- Консольный обработчик ---- if console: console_handler = logging.StreamHandler(sys.stdout) console_handler.setFormatter(formatter) self.logger.addHandler(console_handler) + # - # [END_ENTITY] + # + # @PURPOSE: (Helper) Универсальный метод для вызова соответствующего уровня логирования. + # @INTERNAL + def _log(self, level_method: Any, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None: + level_method(msg, *args, extra=extra, exc_info=exc_info) + # - # -------------------------------------------------------------- - # [ENTITY: Method('_log')] - # -------------------------------------------------------------- - """ - :purpose: Универсальная вспомогательная обёртка над - ``logging.Logger.``. Принимает любые ``*args`` - (подстановочные параметры) и ``extra``‑словарь. - :preconditions: - - ``level_method`` – один из методов ``logger``, - - ``msg`` – строка‑шаблон, - - ``*args`` – значения для ``%``‑подстановок, - - ``extra`` – пользовательские атрибуты (может быть ``None``). - :postconditions: Запись в журнал выполнена. - """ - def _log( - self, - level_method: Any, - msg: str, - *args: Any, - extra: Optional[Mapping[str, Any]] = None, - exc_info: bool = False, - ) -> None: - if extra is not None: - level_method(msg, *args, extra=extra, exc_info=exc_info) - else: - level_method(msg, *args, exc_info=exc_info) - - # [END_ENTITY] - - # -------------------------------------------------------------- - # [ENTITY: Method('info')] - # -------------------------------------------------------------- - """ - :purpose: Записать сообщение уровня INFO. - """ - def info( - self, - msg: str, - *args: Any, - extra: Optional[Mapping[str, Any]] = None, - exc_info: bool = False, - ) -> None: + # + # @PURPOSE: Записывает сообщение уровня INFO. + def info(self, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None: self._log(self.logger.info, msg, *args, extra=extra, exc_info=exc_info) - # [END_ENTITY] + # - # -------------------------------------------------------------- - # [ENTITY: Method('debug')] - # -------------------------------------------------------------- - """ - :purpose: Записать сообщение уровня DEBUG. - """ - def debug( - self, - msg: str, - *args: Any, - extra: Optional[Mapping[str, Any]] = None, - exc_info: bool = False, - ) -> None: + # + # @PURPOSE: Записывает сообщение уровня DEBUG. + def debug(self, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None: self._log(self.logger.debug, msg, *args, extra=extra, exc_info=exc_info) - # [END_ENTITY] + # - # -------------------------------------------------------------- - # [ENTITY: Method('warning')] - # -------------------------------------------------------------- - """ - :purpose: Записать сообщение уровня WARNING. - """ - def warning( - self, - msg: str, - *args: Any, - extra: Optional[Mapping[str, Any]] = None, - exc_info: bool = False, - ) -> None: + # + # @PURPOSE: Записывает сообщение уровня WARNING. + def warning(self, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None: self._log(self.logger.warning, msg, *args, extra=extra, exc_info=exc_info) - # [END_ENTITY] + # - # -------------------------------------------------------------- - # [ENTITY: Method('error')] - # -------------------------------------------------------------- - """ - :purpose: Записать сообщение уровня ERROR. - """ - def error( - self, - msg: str, - *args: Any, - extra: Optional[Mapping[str, Any]] = None, - exc_info: bool = False, - ) -> None: + # + # @PURPOSE: Записывает сообщение уровня ERROR. + def error(self, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None: self._log(self.logger.error, msg, *args, extra=extra, exc_info=exc_info) - # [END_ENTITY] + # - # -------------------------------------------------------------- - # [ENTITY: Method('critical')] - # -------------------------------------------------------------- - """ - :purpose: Записать сообщение уровня CRITICAL. - """ - def critical( - self, - msg: str, - *args: Any, - extra: Optional[Mapping[str, Any]] = None, - exc_info: bool = False, - ) -> None: + # + # @PURPOSE: Записывает сообщение уровня CRITICAL. + def critical(self, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None: self._log(self.logger.critical, msg, *args, extra=extra, exc_info=exc_info) - # [END_ENTITY] + # - # -------------------------------------------------------------- - # [ENTITY: Method('exception')] - # -------------------------------------------------------------- - """ - :purpose: Записать сообщение уровня ERROR вместе с трассировкой - текущего исключения (аналог ``logger.exception``). - """ + # + # @PURPOSE: Записывает сообщение уровня ERROR вместе с трассировкой стека текущего исключения. def exception(self, msg: str, *args: Any, **kwargs: Any) -> None: self.logger.exception(msg, *args, **kwargs) - # [END_ENTITY] + # +# -# -------------------------------------------------------------- -# [END_FILE logger.py] -# -------------------------------------------------------------- \ No newline at end of file +# --- Конец кода модуля --- + +# \ No newline at end of file diff --git a/superset_tool/utils/network.py b/superset_tool/utils/network.py index 67bf32d..fcb0548 100644 --- a/superset_tool/utils/network.py +++ b/superset_tool/utils/network.py @@ -1,265 +1,198 @@ -# -*- coding: utf-8 -*- -# pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument -""" -[MODULE] Сетевой клиент для API +# +# @SEMANTICS: network, http, client, api, requests, session, authentication +# @PURPOSE: Инкапсулирует низкоуровневую HTTP-логику для взаимодействия с Superset API, включая аутентификацию, управление сессией, retry-логику и обработку ошибок. +# @DEPENDS_ON: superset_tool.exceptions -> Для генерации специфичных сетевых и API ошибок. +# @DEPENDS_ON: superset_tool.utils.logger -> Для детального логирования сетевых операций. +# @DEPENDS_ON: requests -> Основа для выполнения HTTP-запросов. -[DESCRIPTION] -Инкапсулирует низкоуровневую HTTP-логику для взаимодействия с Superset API. -""" - -# [IMPORTS] Стандартная библиотека -from typing import Optional, Dict, Any, BinaryIO, List, Union +# +from typing import Optional, Dict, Any, List, Union import json import io from pathlib import Path - -# [IMPORTS] Сторонние библиотеки import requests -import urllib3 # Для отключения SSL-предупреждений +import urllib3 +from superset_tool.exceptions import AuthenticationError, NetworkError, DashboardNotFoundError, SupersetAPIError, PermissionDeniedError +from superset_tool.utils.logger import SupersetLogger +# -# [IMPORTS] Локальные модули -from superset_tool.exceptions import ( - AuthenticationError, - NetworkError, - DashboardNotFoundError, - SupersetAPIError, - PermissionDeniedError -) -from superset_tool.utils.logger import SupersetLogger # Импорт логгера - -# [CONSTANTS] -DEFAULT_RETRIES = 3 -DEFAULT_BACKOFF_FACTOR = 0.5 -DEFAULT_TIMEOUT = 30 +# --- Начало кода модуля --- +# +# @PURPOSE: Инкапсулирует HTTP-логику для работы с API, включая сессии, аутентификацию, и обработку запросов. class APIClient: - """[NETWORK-CORE] Инкапсулирует HTTP-логику для работы с API.""" + DEFAULT_TIMEOUT = 30 - def __init__( - self, - config: Dict[str, Any], - verify_ssl: bool = True, - timeout: int = DEFAULT_TIMEOUT, - logger: Optional[SupersetLogger] = None - ): + def __init__(self, config: Dict[str, Any], verify_ssl: bool = True, timeout: int = DEFAULT_TIMEOUT, logger: Optional[SupersetLogger] = None): + # + # @PURPOSE: Инициализирует API клиент с конфигурацией, сессией и логгером. self.logger = logger or SupersetLogger(name="APIClient") - self.logger.info("[INFO][APIClient.__init__][ENTER] Initializing APIClient.") + self.logger.info("[APIClient.__init__][Enter] Initializing APIClient.") self.base_url = config.get("base_url") self.auth = config.get("auth") - self.request_settings = { - "verify_ssl": verify_ssl, - "timeout": timeout - } + self.request_settings = {"verify_ssl": verify_ssl, "timeout": timeout} self.session = self._init_session() self._tokens: Dict[str, str] = {} self._authenticated = False - self.logger.info("[INFO][APIClient.__init__][SUCCESS] APIClient initialized.") + self.logger.info("[APIClient.__init__][Exit] APIClient initialized.") + # def _init_session(self) -> requests.Session: - self.logger.debug("[DEBUG][APIClient._init_session][ENTER] Initializing session.") + # + # @PURPOSE: Создает и настраивает `requests.Session` с retry-логикой. + # @INTERNAL session = requests.Session() - retries = requests.adapters.Retry( - total=DEFAULT_RETRIES, - backoff_factor=DEFAULT_BACKOFF_FACTOR, - status_forcelist=[500, 502, 503, 504], - allowed_methods={"HEAD", "GET", "POST", "PUT", "DELETE"} - ) + retries = requests.adapters.Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504]) adapter = requests.adapters.HTTPAdapter(max_retries=retries) session.mount('http://', adapter) session.mount('https://', adapter) - verify_ssl = self.request_settings.get("verify_ssl", True) - session.verify = verify_ssl - if not verify_ssl: + if not self.request_settings["verify_ssl"]: urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - self.logger.warning("[WARNING][APIClient._init_session][STATE_CHANGE] SSL verification disabled.") - self.logger.debug("[DEBUG][APIClient._init_session][SUCCESS] Session initialized.") + self.logger.warning("[_init_session][State] SSL verification disabled.") + session.verify = self.request_settings["verify_ssl"] return session + # def authenticate(self) -> Dict[str, str]: - self.logger.info(f"[INFO][APIClient.authenticate][ENTER] Authenticating to {self.base_url}") + # + # @PURPOSE: Выполняет аутентификацию в Superset API и получает access и CSRF токены. + # @POST: `self._tokens` заполнен, `self._authenticated` установлен в `True`. + # @RETURN: Словарь с токенами. + # @THROW: AuthenticationError, NetworkError - при ошибках. + self.logger.info("[authenticate][Enter] Authenticating to %s", self.base_url) try: login_url = f"{self.base_url}/security/login" - response = self.session.post( - login_url, - json=self.auth, - timeout=self.request_settings.get("timeout", DEFAULT_TIMEOUT) - ) + response = self.session.post(login_url, json=self.auth, timeout=self.request_settings["timeout"]) response.raise_for_status() access_token = response.json()["access_token"] + csrf_url = f"{self.base_url}/security/csrf_token/" - csrf_response = self.session.get( - csrf_url, - headers={"Authorization": f"Bearer {access_token}"}, - timeout=self.request_settings.get("timeout", DEFAULT_TIMEOUT) - ) + csrf_response = self.session.get(csrf_url, headers={"Authorization": f"Bearer {access_token}"}, timeout=self.request_settings["timeout"]) csrf_response.raise_for_status() - csrf_token = csrf_response.json()["result"] - self._tokens = { - "access_token": access_token, - "csrf_token": csrf_token - } + + self._tokens = {"access_token": access_token, "csrf_token": csrf_response.json()["result"]} self._authenticated = True - self.logger.info(f"[INFO][APIClient.authenticate][SUCCESS] Authenticated successfully. Tokens {self._tokens}") + self.logger.info("[authenticate][Exit] Authenticated successfully.") return self._tokens except requests.exceptions.HTTPError as e: - self.logger.error(f"[ERROR][APIClient.authenticate][FAILURE] Authentication failed: {e}") raise AuthenticationError(f"Authentication failed: {e}") from e except (requests.exceptions.RequestException, KeyError) as e: - self.logger.error(f"[ERROR][APIClient.authenticate][FAILURE] Network or parsing error: {e}") raise NetworkError(f"Network or parsing error during authentication: {e}") from e + # @property def headers(self) -> Dict[str, str]: - if not self._authenticated: - self.authenticate() + # + # @PURPOSE: Возвращает HTTP-заголовки для аутентифицированных запросов. + if not self._authenticated: self.authenticate() return { "Authorization": f"Bearer {self._tokens['access_token']}", "X-CSRFToken": self._tokens.get("csrf_token", ""), "Referer": self.base_url, "Content-Type": "application/json" } + # - def request( - self, - method: str, - endpoint: str, - headers: Optional[Dict] = None, - raw_response: bool = False, - **kwargs - ) -> Union[requests.Response, Dict[str, Any]]: - self.logger.debug(f"[DEBUG][APIClient.request][ENTER] Requesting {method} {endpoint}") + def request(self, method: str, endpoint: str, headers: Optional[Dict] = None, raw_response: bool = False, **kwargs) -> Union[requests.Response, Dict[str, Any]]: + # + # @PURPOSE: Выполняет универсальный HTTP-запрос к API. + # @RETURN: `requests.Response` если `raw_response=True`, иначе `dict`. + # @THROW: SupersetAPIError, NetworkError и их подклассы. full_url = f"{self.base_url}{endpoint}" _headers = self.headers.copy() - if headers: - _headers.update(headers) - timeout = kwargs.pop('timeout', self.request_settings.get("timeout", DEFAULT_TIMEOUT)) + if headers: _headers.update(headers) + try: - response = self.session.request( - method, - full_url, - headers=_headers, - timeout=timeout, - **kwargs - ) + response = self.session.request(method, full_url, headers=_headers, **kwargs) response.raise_for_status() - self.logger.debug(f"[DEBUG][APIClient.request][SUCCESS] Request successful for {method} {endpoint}") return response if raw_response else response.json() except requests.exceptions.HTTPError as e: - self.logger.error(f"[ERROR][APIClient.request][FAILURE] HTTP error for {method} {endpoint}: {e}") - self._handle_http_error(e, endpoint, context={}) + self._handle_http_error(e, endpoint) except requests.exceptions.RequestException as e: - self.logger.error(f"[ERROR][APIClient.request][FAILURE] Network error for {method} {endpoint}: {e}") self._handle_network_error(e, full_url) + # - def _handle_http_error(self, e, endpoint, context): + def _handle_http_error(self, e: requests.exceptions.HTTPError, endpoint: str): + # + # @PURPOSE: (Helper) Преобразует HTTP ошибки в кастомные исключения. + # @INTERNAL status_code = e.response.status_code - if status_code == 404: - raise DashboardNotFoundError(endpoint, context=context) from e - if status_code == 403: - raise PermissionDeniedError("Доступ запрещен.", **context) from e - if status_code == 401: - raise AuthenticationError("Аутентификация не удалась.", **context) from e - raise SupersetAPIError(f"Ошибка API: {status_code} - {e.response.text}", **context) from e + if status_code == 404: raise DashboardNotFoundError(endpoint) from e + if status_code == 403: raise PermissionDeniedError() from e + if status_code == 401: raise AuthenticationError() from e + raise SupersetAPIError(f"API Error {status_code}: {e.response.text}") from e + # - def _handle_network_error(self, e, url): - if isinstance(e, requests.exceptions.Timeout): - msg = "Таймаут запроса" - elif isinstance(e, requests.exceptions.ConnectionError): - msg = "Ошибка соединения" - else: - msg = f"Неизвестная сетевая ошибка: {e}" + def _handle_network_error(self, e: requests.exceptions.RequestException, url: str): + # + # @PURPOSE: (Helper) Преобразует сетевые ошибки в `NetworkError`. + # @INTERNAL + if isinstance(e, requests.exceptions.Timeout): msg = "Request timeout" + elif isinstance(e, requests.exceptions.ConnectionError): msg = "Connection error" + else: msg = f"Unknown network error: {e}" raise NetworkError(msg, url=url) from e + # - def upload_file( - self, - endpoint: str, - file_info: Dict[str, Any], - extra_data: Optional[Dict] = None, - timeout: Optional[int] = None - ) -> Dict: - self.logger.info(f"[INFO][APIClient.upload_file][ENTER] Uploading file to {endpoint}") + def upload_file(self, endpoint: str, file_info: Dict[str, Any], extra_data: Optional[Dict] = None, timeout: Optional[int] = None) -> Dict: + # + # @PURPOSE: Загружает файл на сервер через multipart/form-data. + # @RETURN: Ответ API в виде словаря. + # @THROW: SupersetAPIError, NetworkError, TypeError. full_url = f"{self.base_url}{endpoint}" - _headers = self.headers.copy() - _headers.pop('Content-Type', None) - file_obj = file_info.get("file_obj") - file_name = file_info.get("file_name") - form_field = file_info.get("form_field", "file") + _headers = self.headers.copy(); _headers.pop('Content-Type', None) + + file_obj, file_name, form_field = file_info.get("file_obj"), file_info.get("file_name"), file_info.get("form_field", "file") + + files_payload = {} if isinstance(file_obj, (str, Path)): - with open(file_obj, 'rb') as file_to_upload: - files_payload = {form_field: (file_name, file_to_upload, 'application/x-zip-compressed')} - return self._perform_upload(full_url, files_payload, extra_data, _headers, timeout) + with open(file_obj, 'rb') as f: + files_payload = {form_field: (file_name, f.read(), 'application/x-zip-compressed')} elif isinstance(file_obj, io.BytesIO): files_payload = {form_field: (file_name, file_obj.getvalue(), 'application/x-zip-compressed')} - return self._perform_upload(full_url, files_payload, extra_data, _headers, timeout) - elif hasattr(file_obj, 'read'): - files_payload = {form_field: (file_name, file_obj, 'application/x-zip-compressed')} - return self._perform_upload(full_url, files_payload, extra_data, _headers, timeout) else: - self.logger.error(f"[ERROR][APIClient.upload_file][FAILURE] Unsupported file_obj type: {type(file_obj)}") - raise TypeError(f"Неподдерживаемый тип 'file_obj': {type(file_obj)}") + raise TypeError(f"Unsupported file_obj type: {type(file_obj)}") + + return self._perform_upload(full_url, files_payload, extra_data, _headers, timeout) + # - def _perform_upload(self, url, files, data, headers, timeout): - self.logger.debug(f"[DEBUG][APIClient._perform_upload][ENTER] Performing upload to {url}") + def _perform_upload(self, url: str, files: Dict, data: Optional[Dict], headers: Dict, timeout: Optional[int]) -> Dict: + # + # @PURPOSE: (Helper) Выполняет POST запрос с файлом. + # @INTERNAL try: - response = self.session.post( - url=url, - files=files, - data=data or {}, - headers=headers, - timeout=timeout or self.request_settings.get("timeout") - ) + response = self.session.post(url, files=files, data=data or {}, headers=headers, timeout=timeout or self.request_settings["timeout"]) response.raise_for_status() - self.logger.info(f"[INFO][APIClient._perform_upload][SUCCESS] Upload successful to {url}") return response.json() except requests.exceptions.HTTPError as e: - self.logger.error(f"[ERROR][APIClient._perform_upload][FAILURE] HTTP error during upload: {e}") - raise SupersetAPIError(f"Ошибка API при загрузке: {e.response.text}") from e + raise SupersetAPIError(f"API error during upload: {e.response.text}") from e except requests.exceptions.RequestException as e: - self.logger.error(f"[ERROR][APIClient._perform_upload][FAILURE] Network error during upload: {e}") - raise NetworkError(f"Ошибка сети при загрузке: {e}", url=url) from e + raise NetworkError(f"Network error during upload: {e}", url=url) from e + # - def fetch_paginated_count( - self, - endpoint: str, - query_params: Dict, - count_field: str = "count", - timeout: Optional[int] = None - ) -> int: - self.logger.debug(f"[DEBUG][APIClient.fetch_paginated_count][ENTER] Fetching paginated count for {endpoint}") - response_json = self.request( - method="GET", - endpoint=endpoint, - params={"q": json.dumps(query_params)}, - timeout=timeout or self.request_settings.get("timeout") - ) - count = response_json.get(count_field, 0) - self.logger.debug(f"[DEBUG][APIClient.fetch_paginated_count][SUCCESS] Fetched paginated count: {count}") - return count + def fetch_paginated_count(self, endpoint: str, query_params: Dict, count_field: str = "count") -> int: + # + # @PURPOSE: Получает общее количество элементов для пагинации. + response_json = self.request("GET", endpoint, params={"q": json.dumps(query_params)}) + return response_json.get(count_field, 0) + # - def fetch_paginated_data( - self, - endpoint: str, - pagination_options: Dict[str, Any], - timeout: Optional[int] = None - ) -> List[Any]: - self.logger.debug(f"[DEBUG][APIClient.fetch_paginated_data][ENTER] Fetching paginated data for {endpoint}") - base_query = pagination_options.get("base_query", {}) - total_count = pagination_options.get("total_count", 0) - results_field = pagination_options.get("results_field", "result") - page_size = base_query.get('page_size') - if not page_size or page_size <= 0: - raise ValueError("'page_size' должен быть положительным числом.") - total_pages = (total_count + page_size - 1) // page_size + def fetch_paginated_data(self, endpoint: str, pagination_options: Dict[str, Any]) -> List[Any]: + # + # @PURPOSE: Автоматически собирает данные со всех страниц пагинированного эндпоинта. + base_query, total_count = pagination_options["base_query"], pagination_options["total_count"] + results_field, page_size = pagination_options["results_field"], base_query.get('page_size') + assert page_size and page_size > 0, "'page_size' must be a positive number." + results = [] - for page in range(total_pages): + for page in range((total_count + page_size - 1) // page_size): query = {**base_query, 'page': page} - response_json = self.request( - method="GET", - endpoint=endpoint, - params={"q": json.dumps(query)}, - timeout=timeout or self.request_settings.get("timeout") - ) - page_results = response_json.get(results_field, []) - results.extend(page_results) - self.logger.debug(f"[DEBUG][APIClient.fetch_paginated_data][SUCCESS] Fetched paginated data. Total items: {len(results)}") - return results \ No newline at end of file + response_json = self.request("GET", endpoint, params={"q": json.dumps(query)}) + results.extend(response_json.get(results_field, [])) + return results + # + +# + +# --- Конец кода модуля --- + +# \ No newline at end of file diff --git a/superset_tool/utils/whiptail_fallback.py b/superset_tool/utils/whiptail_fallback.py index d2ef135..b253917 100644 --- a/superset_tool/utils/whiptail_fallback.py +++ b/superset_tool/utils/whiptail_fallback.py @@ -1,148 +1,106 @@ -# [MODULE_PATH] superset_tool.utils.whiptail_fallback -# [FILE] whiptail_fallback.py -# [SEMANTICS] ui, fallback, console, utils, non‑interactive +# +# @SEMANTICS: ui, fallback, console, utility, interactive +# @PURPOSE: Предоставляет плотный консольный UI-fallback для интерактивных диалогов, имитируя `whiptail` для систем, где он недоступен. -# -------------------------------------------------------------- -# [IMPORTS] -# -------------------------------------------------------------- +# import sys from typing import List, Tuple, Optional, Any -# [END_IMPORTS] +# -# -------------------------------------------------------------- -# [ENTITY: Service('ConsoleUI')] -# -------------------------------------------------------------- -""" -:purpose: Плотный консольный UI‑fallback для всех функций, - которые в оригинальном проекте использовали ``whiptail``. - Всё взаимодействие теперь **не‑интерактивно**: функции, - выводящие сообщение, просто печатают его без ожидания - ``Enter``. -""" +# --- Начало кода модуля --- -def menu( - title: str, - prompt: str, - choices: List[str], - backtitle: str = "Superset Migration Tool", -) -> Tuple[int, Optional[str]]: - """Return (rc, selected item). rc == 0 → OK.""" - print(f"\n=== {title} ===") - print(prompt) +# +# @PURPOSE: Отображает меню выбора и возвращает выбранный элемент. +# @PARAM: title: str - Заголовок меню. +# @PARAM: prompt: str - Приглашение к вводу. +# @PARAM: choices: List[str] - Список вариантов для выбора. +# @RETURN: Tuple[int, Optional[str]] - Кортеж (код возврата, выбранный элемент). rc=0 - успех. +def menu(title: str, prompt: str, choices: List[str], **kwargs) -> Tuple[int, Optional[str]]: + print(f"\n=== {title} ===\n{prompt}") for idx, item in enumerate(choices, 1): print(f"{idx}) {item}") - try: raw = input("\nВведите номер (0 – отмена): ").strip() sel = int(raw) - if sel == 0: - return 1, None - return 0, choices[sel - 1] - except Exception: + return (0, choices[sel - 1]) if 0 < sel <= len(choices) else (1, None) + except (ValueError, IndexError): return 1, None +# - -def checklist( - title: str, - prompt: str, - options: List[Tuple[str, str]], - backtitle: str = "Superset Migration Tool", -) -> Tuple[int, List[str]]: - """Return (rc, list of selected **values**).""" - print(f"\n=== {title} ===") - print(prompt) +# +# @PURPOSE: Отображает список с возможностью множественного выбора. +# @PARAM: title: str - Заголовок. +# @PARAM: prompt: str - Приглашение к вводу. +# @PARAM: options: List[Tuple[str, str]] - Список кортежей (значение, метка). +# @RETURN: Tuple[int, List[str]] - Кортеж (код возврата, список выбранных значений). +def checklist(title: str, prompt: str, options: List[Tuple[str, str]], **kwargs) -> Tuple[int, List[str]]: + print(f"\n=== {title} ===\n{prompt}") for idx, (val, label) in enumerate(options, 1): print(f"{idx}) [{val}] {label}") - raw = input("\nВведите номера через запятую (пустой ввод → отказ): ").strip() - if not raw: - return 1, [] - + if not raw: return 1, [] try: - indices = {int(x) for x in raw.split(",") if x.strip()} - selected = [options[i - 1][0] for i in indices if 0 < i <= len(options)] - return 0, selected - except Exception: + indices = {int(x.strip()) for x in raw.split(",") if x.strip()} + selected_values = [options[i - 1][0] for i in indices if 0 < i <= len(options)] + return 0, selected_values + except (ValueError, IndexError): return 1, [] +# - -def yesno( - title: str, - question: str, - backtitle: str = "Superset Migration Tool", -) -> bool: - """True → пользователь ответил «да». """ +# +# @PURPOSE: Задает вопрос с ответом да/нет. +# @PARAM: title: str - Заголовок. +# @PARAM: question: str - Вопрос для пользователя. +# @RETURN: bool - `True`, если пользователь ответил "да". +def yesno(title: str, question: str, **kwargs) -> bool: ans = input(f"\n=== {title} ===\n{question} (y/n): ").strip().lower() return ans in ("y", "yes", "да", "д") +# - -def msgbox( - title: str, - msg: str, - width: int = 60, - height: int = 15, - backtitle: str = "Superset Migration Tool", -) -> None: - """Простой вывод сообщения – без ожидания Enter.""" +# +# @PURPOSE: Отображает информационное сообщение. +# @PARAM: title: str - Заголовок. +# @PARAM: msg: str - Текст сообщения. +def msgbox(title: str, msg: str, **kwargs) -> None: print(f"\n=== {title} ===\n{msg}\n") - # **Убрано:** input("Нажмите для продолжения...") +# - -def inputbox( - title: str, - prompt: str, - backtitle: str = "Superset Migration Tool", -) -> Tuple[int, Optional[str]]: - """Return (rc, введённая строка). rc == 0 → успешно.""" +# +# @PURPOSE: Запрашивает у пользователя текстовый ввод. +# @PARAM: title: str - Заголовок. +# @PARAM: prompt: str - Приглашение к вводу. +# @RETURN: Tuple[int, Optional[str]] - Кортеж (код возврата, введенная строка). +def inputbox(title: str, prompt: str, **kwargs) -> Tuple[int, Optional[str]]: print(f"\n=== {title} ===") val = input(f"{prompt}\n") - if val == "": - return 1, None - return 0, val - - -# -------------------------------------------------------------- -# [ENTITY: Service('ConsoleGauge')] -# -------------------------------------------------------------- -""" -:purpose: Минимальная имитация ``whiptail``‑gauge в консоли. -""" + return (0, val) if val else (1, None) +# +# +# @PURPOSE: Контекстный менеджер для имитации `whiptail gauge` в консоли. +# @INTERNAL class _ConsoleGauge: - """Контекст‑менеджер для простого прогресс‑бара.""" - def __init__(self, title: str, width: int = 60, height: int = 10): + def __init__(self, title: str, **kwargs): self.title = title - self.width = width - self.height = height - self._percent = 0 - def __enter__(self): print(f"\n=== {self.title} ===") return self - def __exit__(self, exc_type, exc_val, exc_tb): - sys.stdout.write("\n") - sys.stdout.flush() - + sys.stdout.write("\n"); sys.stdout.flush() def set_text(self, txt: str) -> None: - sys.stdout.write(f"\r{txt} ") - sys.stdout.flush() - + sys.stdout.write(f"\r{txt} "); sys.stdout.flush() def set_percent(self, percent: int) -> None: - self._percent = percent - sys.stdout.write(f"{percent}%") - sys.stdout.flush() -# [END_ENTITY] + sys.stdout.write(f"{percent}%"); sys.stdout.flush() +# -def gauge( - title: str, - width: int = 60, - height: int = 10, -) -> Any: - """Always returns the console fallback gauge.""" - return _ConsoleGauge(title, width, height) -# [END_ENTITY] +# +# @PURPOSE: Создает и возвращает экземпляр `_ConsoleGauge`. +# @PARAM: title: str - Заголовок для индикатора прогресса. +# @RETURN: _ConsoleGauge - Экземпляр контекстного менеджера. +def gauge(title: str, **kwargs) -> _ConsoleGauge: + return _ConsoleGauge(title, **kwargs) +# -# -------------------------------------------------------------- -# [END_FILE whiptail_fallback.py] -# -------------------------------------------------------------- \ No newline at end of file +# --- Конец кода модуля --- + +# \ No newline at end of file diff --git a/tech_spec/Пример GET.md b/tech_spec/Пример GET.md new file mode 100644 index 0000000..789d7df --- /dev/null +++ b/tech_spec/Пример GET.md @@ -0,0 +1,3096 @@ +curl -X 'GET' \ + 'https://devta.bi.dwh.rusal.com/api/v1/dataset/100?q=%7B%0A%20%20%22columns%22%3A%20%5B%0A%20%20%20%20%22string%22%0A%20%20%5D%2C%0A%20%20%22keys%22%3A%20%5B%0A%20%20%20%20%22label_columns%22%0A%20%20%5D%0A%7D' \ + -H 'accept: application/json' + + + +{ + "id": 100, + "label_columns": { + "cache_timeout": "Тайм-аут Кэша", + "changed_by.first_name": "Changed By First Name", + "changed_by.last_name": "Changed By Last Name", + "changed_on": "Changed On", + "changed_on_humanized": "Changed On Humanized", + "column_formats": "Column Formats", + "columns.advanced_data_type": "Columns Advanced Data Type", + "columns.changed_on": "Columns Changed On", + "columns.column_name": "Columns Column Name", + "columns.created_on": "Columns Created On", + "columns.description": "Columns Description", + "columns.expression": "Columns Expression", + "columns.extra": "Columns Extra", + "columns.filterable": "Columns Filterable", + "columns.groupby": "Columns Groupby", + "columns.id": "Columns Id", + "columns.is_active": "Columns Is Active", + "columns.is_dttm": "Columns Is Dttm", + "columns.python_date_format": "Columns Python Date Format", + "columns.type": "Columns Type", + "columns.type_generic": "Columns Type Generic", + "columns.uuid": "Columns Uuid", + "columns.verbose_name": "Columns Verbose Name", + "created_by.first_name": "Created By First Name", + "created_by.last_name": "Created By Last Name", + "created_on": "Created On", + "created_on_humanized": "Created On Humanized", + "currency_formats": "Currency Formats", + "database.backend": "Database Backend", + "database.database_name": "Database Database Name", + "database.id": "Database Id", + "datasource_name": "Datasource Name", + "datasource_type": "Datasource Type", + "default_endpoint": "URL для редиректа", + "description": "Описание", + "extra": "Дополнительные параметры", + "fetch_values_predicate": "Получить значения предиката", + "filter_select_enabled": "Filter Select Enabled", + "granularity_sqla": "Granularity Sqla", + "id": "id", + "is_managed_externally": "Is Managed Externally", + "is_sqllab_view": "Is Sqllab View", + "kind": "Kind", + "main_dttm_col": "Main Dttm Col", + "metrics.changed_on": "Metrics Changed On", + "metrics.created_on": "Metrics Created On", + "metrics.currency": "Metrics Currency", + "metrics.d3format": "Metrics D3Format", + "metrics.description": "Metrics Description", + "metrics.expression": "Metrics Expression", + "metrics.extra": "Metrics Extra", + "metrics.id": "Metrics Id", + "metrics.metric_name": "Metrics Metric Name", + "metrics.metric_type": "Metrics Metric Type", + "metrics.verbose_name": "Metrics Verbose Name", + "metrics.warning_text": "Metrics Warning Text", + "name": "Название", + "normalize_columns": "Normalize Columns", + "offset": "Смещение", + "order_by_choices": "Order By Choices", + "owners.first_name": "Owners First Name", + "owners.id": "Owners Id", + "owners.last_name": "Owners Last Name", + "schema": "Схема", + "select_star": "Select Star", + "sql": "Sql", + "table_name": "Имя Таблицы", + "template_params": "Template Params", + "time_grain_sqla": "Time Grain Sqla", + "uid": "Uid", + "url": "Url", + "verbose_map": "Verbose Map" + }, + "result": { + "cache_timeout": null, + "changed_by": { + "first_name": "Андрей", + "last_name": "Ткаченко" + }, + "changed_on": "2025-04-25T08:44:53.313824", + "changed_on_humanized": "5 месяцев назад", + "column_formats": {}, + "columns": [ + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.378224", + "column_name": "debt_balance_subposition_document_currency_amount", + "created_on": "2025-01-21T07:39:19.378221", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6061, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "4adde4e8-12b4-4e52-8c88-6fbe5f5bfe03", + "verbose_name": "Остаток КЗ по данной позиции, в валюте документа" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.384161", + "column_name": "position_line_item", + "created_on": "2025-01-21T07:39:19.384158", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6062, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "eee9d143-c73e-49e6-aafb-4605a9e8968d", + "verbose_name": "Номер строки проводки в рамках бухгалтерского документа " + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.390263", + "column_name": "debt_subposition_second_local_currency_amount", + "created_on": "2025-01-21T07:39:19.390260", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6063, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "2cb2d87d-fc6e-4e42-a332-2485585b1a8a", + "verbose_name": "Сумма задолженности подпозиции во второй местной валюте" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.396079", + "column_name": "general_ledger_account_full_name", + "created_on": "2025-01-21T07:39:19.396076", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6064, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "0b8ddb1d-ff01-4a4e-8a24-0a8a35fff12a", + "verbose_name": "Подробный текст к основному счету на русском" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.402006", + "column_name": "dt_overdue", + "created_on": "2025-01-21T07:39:19.402003", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6065, + "is_active": true, + "is_dttm": true, + "python_date_format": null, + "type": "Nullable(Date)", + "type_generic": 2, + "uuid": "9e67e1e2-5066-4f9a-a90c-2adbd232a8fd", + "verbose_name": "Дата, когда задолженность станет просроченной " + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.408751", + "column_name": "debt_balance_document_currency_amount", + "created_on": "2025-01-21T07:39:19.408748", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6066, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "2758bef5-d965-4151-b147-bc6541b9ad85", + "verbose_name": "Остаток задолженности в валюте документа " + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.414482", + "column_name": "debt_subposition_local_currency_amount", + "created_on": "2025-01-21T07:39:19.414479", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6067, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "becdba7d-389e-4a60-bbe4-6b21ea8aa0ce", + "verbose_name": "Сумма задолженности подпозиции в местной валюте" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.420394", + "column_name": "debt_subposition_document_currency_amount", + "created_on": "2025-01-21T07:39:19.420391", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6068, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "6b174693-49a1-4c06-931d-cb60aa53bf5c", + "verbose_name": "Сумма задолженности подпозиции в валюте документа" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.426104", + "column_name": "dt_baseline_due_date_calculation", + "created_on": "2025-01-21T07:39:19.426101", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6069, + "is_active": true, + "is_dttm": true, + "python_date_format": null, + "type": "Nullable(Date)", + "type_generic": 2, + "uuid": "6f56625b-be8d-4ba6-bc39-67dea909b603", + "verbose_name": "Базовая дата для расчета срока оплаты" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.431831", + "column_name": "debt_balance_exchange_diff_second_local_currency_amount", + "created_on": "2025-01-21T07:39:19.431828", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6070, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "9adcd904-738f-45cc-a643-f5c20a3a5dd0", + "verbose_name": "ВВ2 Курсовая разница остатка позиции" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.437513", + "column_name": "debt_balance_subposition_usd_amount", + "created_on": "2025-01-21T07:39:19.437510", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6071, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "2a861c2b-848b-470f-bad5-f4c560bb86cc", + "verbose_name": "Сумма задолженности подпозиции в USD" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.443146", + "column_name": "debt_balance_exchange_diff_local_currency_amount", + "created_on": "2025-01-21T07:39:19.443143", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6072, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "ac0fec26-4cd2-43b6-b296-a475c3033829", + "verbose_name": "ВВ Курсовая разница остатка позиции" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.448797", + "column_name": "debt_balance_second_local_currency_amount", + "created_on": "2025-01-21T07:39:19.448794", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6073, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "e0bfa2e1-2a76-455d-b591-513b86d5fca2", + "verbose_name": "Остаток задолженности во второй валюте" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.454340", + "column_name": "debt_balance_local_currency_amount", + "created_on": "2025-01-21T07:39:19.454337", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6074, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "e4b41d22-9acf-4aaa-b342-2f6783877dca", + "verbose_name": "Остаток задолженности в валюте организации" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.459825", + "column_name": "general_ledger_account_code", + "created_on": "2025-01-21T07:39:19.459822", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6075, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "10e29652-0d81-4aca-bdf4-b764eef848fe", + "verbose_name": "Основной счет главной книги " + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.465491", + "column_name": "contract_supervisor_employee_number", + "created_on": "2025-01-21T07:39:19.465487", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6076, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "c1f44e1f-0a60-4860-8ae0-72eabbe9b854", + "verbose_name": "Куратор договора, таб №" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.471010", + "column_name": "funds_center_name", + "created_on": "2025-01-21T07:39:19.471007", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6077, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "433b7257-7665-475e-8299-653f0acd8b70", + "verbose_name": "Подразделение финансового менеджмента, название" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.476645", + "column_name": "funds_center_code", + "created_on": "2025-01-21T07:39:19.476642", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6078, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "a3eff887-2334-4228-8677-db048b1b637f", + "verbose_name": "Подразделение финансового менеджмента, код" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.482092", + "column_name": "contract_trader_code", + "created_on": "2025-01-21T07:39:19.482089", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6079, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "97df5359-4b25-4a74-8307-af3bf6086d33", + "verbose_name": "Табельный номер трейдера договора" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.487832", + "column_name": "document_currency_amount", + "created_on": "2025-01-21T07:39:19.487829", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6080, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "2f64a898-31a1-405b-a8d4-e82735e58a23", + "verbose_name": "Сумма в валюте документа" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.493436", + "column_name": "dt_debt", + "created_on": "2025-01-21T07:39:19.493432", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6081, + "is_active": true, + "is_dttm": true, + "python_date_format": null, + "type": "Nullable(Date)", + "type_generic": 2, + "uuid": "fc615735-f55e-4ce6-a465-d9d1d772d892", + "verbose_name": "Дата возникновения задолженности " + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.499104", + "column_name": "second_local_currency_code", + "created_on": "2025-01-21T07:39:19.499101", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6082, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "cac50028-8117-49bf-b0f8-7e94c561fbfa", + "verbose_name": "Код второй внутренней валюты" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.504787", + "column_name": "reference_document_number", + "created_on": "2025-01-21T07:39:19.504784", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6083, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "8a5b92f8-5eac-4f08-a969-e09a7e808a87", + "verbose_name": "Ссылочный номер документа " + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.510503", + "column_name": "reverse_document_code", + "created_on": "2025-01-21T07:39:19.510500", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6084, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "636f6cc4-f4c8-466c-aacc-33bf96cf7cb9", + "verbose_name": "№ документа сторно " + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.516145", + "column_name": "tax_code", + "created_on": "2025-01-21T07:39:19.516142", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6085, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "6123c3d7-9efb-4d38-bfa2-5b3c652b65f2", + "verbose_name": "Код налога с оборота" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.521942", + "column_name": "purchase_or_sales_group_name", + "created_on": "2025-01-21T07:39:19.521939", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6086, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "cbf45180-fc9f-4973-a9a1-7663e1fa820d", + "verbose_name": "Группа закупок/сбыта, Наименование" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.527592", + "column_name": "purchase_or_sales_group_code", + "created_on": "2025-01-21T07:39:19.527588", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6087, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "cd2d88ad-b9c1-499e-a015-e1dd57a13377", + "verbose_name": "Группа закупок/сбыта, Код" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.533186", + "column_name": "contract_supervisor_name", + "created_on": "2025-01-21T07:39:19.533183", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6088, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "d7e7634d-83e8-4196-981e-725bfc6c2a33", + "verbose_name": "Куратор договора, ФИО" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.539252", + "column_name": "responsibility_center_name", + "created_on": "2025-01-21T07:39:19.539249", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6089, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "a6e73a3c-0dac-4fde-a508-4fc0042fddc5", + "verbose_name": "Центр ответственности, наименование" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.544941", + "column_name": "responsibility_center_code", + "created_on": "2025-01-21T07:39:19.544938", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6090, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "51c606a3-7888-49dc-891b-e00896e4f6fd", + "verbose_name": "Центр ответственности, код" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.550750", + "column_name": "debt_subposition_number", + "created_on": "2025-01-21T07:39:19.550747", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6091, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "ca64f372-43a7-472a-b8b5-7521eec3b4f0", + "verbose_name": "Номер подпозиции задолженности" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.556389", + "column_name": "terms_of_payment_name", + "created_on": "2025-01-21T07:39:19.556385", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6092, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "b5306d47-8330-4131-947e-700ac13c0a89", + "verbose_name": "Наименование условия платежа" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.561893", + "column_name": "terms_of_payment_code", + "created_on": "2025-01-21T07:39:19.561890", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6093, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "4b24e85c-69f1-4bf5-82be-f9dc61871a22", + "verbose_name": "Код условий платежа" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.567655", + "column_name": "position_line_item_text", + "created_on": "2025-01-21T07:39:19.567652", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6094, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "2a8a052a-44d8-448d-b45a-c1b9796b8b40", + "verbose_name": "Текст к позиции" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.573197", + "column_name": "contract_trader_name", + "created_on": "2025-01-21T07:39:19.573194", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6095, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "eb8456e0-6195-445e-9532-71c318b0f8b2", + "verbose_name": "ФИО трейдера договора" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.579140", + "column_name": "external_contract_number", + "created_on": "2025-01-21T07:39:19.579137", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6096, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "1b8d9238-690f-4dd4-af50-b943190f0459", + "verbose_name": "Внешний номер договора" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.584862", + "column_name": "accounting_document_code", + "created_on": "2025-01-21T07:39:19.584859", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6097, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "ed54f536-43ef-4dc1-b40c-1a1898c8a67f", + "verbose_name": "Номер бухгалтерского документа" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.590511", + "column_name": "local_currency_code", + "created_on": "2025-01-21T07:39:19.590508", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6098, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "b5e5b547-89df-425e-8426-94f91a89b734", + "verbose_name": "Код внутренней валюты" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.596142", + "column_name": "clearing_document_code", + "created_on": "2025-01-21T07:39:19.596139", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6099, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "9f6f449c-c089-4c7e-ac69-edc90da69e41", + "verbose_name": "Номер документа выравнивания" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.601812", + "column_name": "document_currency_code", + "created_on": "2025-01-21T07:39:19.601809", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6100, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "de95ce01-0741-4f71-b1e0-c53388de7331", + "verbose_name": "Код валюты документа" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.607340", + "column_name": "country_code", + "created_on": "2025-01-21T07:39:19.607337", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6101, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "ef29b651-737c-48b4-aa5c-47c600a9a7b1", + "verbose_name": "Страна регистрации контрагента" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.618464", + "column_name": "dt_accounting_document", + "created_on": "2025-01-21T07:39:19.618461", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6103, + "is_active": true, + "is_dttm": true, + "python_date_format": null, + "type": "Nullable(Date)", + "type_generic": 2, + "uuid": "090a514f-d266-4eef-8464-1db990ac23ae", + "verbose_name": "Дата документа" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.624135", + "column_name": "dt_clearing", + "created_on": "2025-01-21T07:39:19.624132", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6104, + "is_active": true, + "is_dttm": true, + "python_date_format": null, + "type": "Nullable(Date)", + "type_generic": 2, + "uuid": "8d66942a-3989-4f2c-9b5e-849836ae782e", + "verbose_name": "Дата выравнивания" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.629772", + "column_name": "unit_balance_name", + "created_on": "2025-01-21T07:39:19.629769", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6105, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "d4f93379-6280-4a97-a3e9-942566b0ddb2", + "verbose_name": "Название БЕ" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.635548", + "column_name": "counterparty_full_name", + "created_on": "2025-01-21T07:39:19.635544", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6106, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "3cfda8c5-7a1c-4da4-b36b-d13d0e7a1c66", + "verbose_name": "Наименование контрагента" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.641084", + "column_name": "accounting_document_type", + "created_on": "2025-01-21T07:39:19.641081", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6107, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "91166cf0-ed43-4f99-8b2a-3044f3e3d47e", + "verbose_name": "Вид документа" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.646660", + "column_name": "budget_subtype_code", + "created_on": "2025-01-21T07:39:19.646657", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6108, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "0b143e81-71c8-4074-aabb-62627f0f5e5c", + "verbose_name": "Подвид бюджета" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.652166", + "column_name": "debt_period_group", + "created_on": "2025-01-21T07:39:19.652163", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6109, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "13292f04-eeac-4e8e-88fb-55a5c60b92ae", + "verbose_name": "Период ПДЗ" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.657791", + "column_name": "plant_name", + "created_on": "2025-01-21T07:39:19.657788", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6110, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "f906f7cf-033c-4263-88c9-cbace27835b6", + "verbose_name": "Название филиала" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.663369", + "column_name": "contract_number", + "created_on": "2025-01-21T07:39:19.663366", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6111, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "1e73fe7d-c1bb-4f93-9f3e-74406cf03a13", + "verbose_name": "Номер договора" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.668898", + "column_name": "assignment_number", + "created_on": "2025-01-21T07:39:19.668895", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6112, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "ef22d340-c9d2-4621-b334-bf49aa0db58f", + "verbose_name": "Номер присвоения" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.674382", + "column_name": "account_type", + "created_on": "2025-01-21T07:39:19.674379", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6113, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "5a78a1a0-d66c-41e4-81e6-04d73531b3eb", + "verbose_name": "Вид счета" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.679916", + "column_name": "unit_balance_code", + "created_on": "2025-01-21T07:39:19.679913", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6114, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "String", + "type_generic": 1, + "uuid": "d359e0c4-9fbc-41dc-8289-4eca80794aa2", + "verbose_name": "Балансовая единица" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.685491", + "column_name": "debit_or_credit", + "created_on": "2025-01-21T07:39:19.685488", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6115, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "5f811916-4d71-439b-86bd-4d143788d0c4", + "verbose_name": "Д/К" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.365982", + "column_name": "debt_balance_subposition_second_local_currency_amount", + "created_on": "2025-01-21T07:39:19.365979", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6059, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "2337bf4b-87b4-47ce-9f40-24234f846620", + "verbose_name": "Остаток КЗ по данной позиции, во второй валюте" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.372190", + "column_name": "debt_balance_subposition_local_currency_amount", + "created_on": "2025-01-21T07:39:19.372187", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6060, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "98b38bc5-2afa-4d4c-95c9-0e602998fbc1", + "verbose_name": "Остаток КЗ по данной позиции, в валюте БЕ" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.612886", + "column_name": "fiscal_year", + "created_on": "2025-01-21T07:39:19.612883", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6102, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "30e413ec-ab53-474d-95f6-de2780a513d2", + "verbose_name": "Фин. год." + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.691167", + "column_name": "local_currency_amount", + "created_on": "2025-01-21T07:39:19.691164", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6116, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "fe0b6b84-f520-4c89-b8dd-99eb2a2df5cd", + "verbose_name": "" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.696778", + "column_name": "dt", + "created_on": "2025-01-21T07:39:19.696775", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6117, + "is_active": true, + "is_dttm": true, + "python_date_format": null, + "type": "Nullable(Date)", + "type_generic": 2, + "uuid": "a13ac02e-a131-4428-a4cb-4df256956129", + "verbose_name": "Дата" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.702431", + "column_name": "accounting_document_status_code", + "created_on": "2025-01-21T07:39:19.702428", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6118, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "1bb3d168-23d9-468f-9392-bcdec99e9c0c", + "verbose_name": "" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.708083", + "column_name": "plant_code", + "created_on": "2025-01-21T07:39:19.708080", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6119, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "66afeccb-d97c-4823-902d-278f6906db3b", + "verbose_name": "Завод" + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.713844", + "column_name": "plant_code-plant_name", + "created_on": "2025-01-21T07:39:19.713841", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6120, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "d038c224-d00d-41d0-96f9-0a6741bdd10c", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.719516", + "column_name": "responsibility_center_level1_name", + "created_on": "2025-01-21T07:39:19.719513", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6121, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "b42643bd-efc3-4978-b2de-2b4027a0066f", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.725106", + "column_name": "responsibility_center_level1_code", + "created_on": "2025-01-21T07:39:19.725103", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6122, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "76a8972a-3513-46da-88a0-cd59da4ed6cb", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.730795", + "column_name": "debt_balance_subpos_exch_diff_second_local_curr_amount", + "created_on": "2025-01-21T07:39:19.730792", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6123, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "5a5b0dbf-1604-4792-9dbe-2df61d02c519", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.736576", + "column_name": "debt_balance_subpos_second_local_currency_amount_reval", + "created_on": "2025-01-21T07:39:19.736573", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6124, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "0bb4b94a-ce55-4a63-b589-54235d57917f", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.742139", + "column_name": "debt_balance_with_revaluation_diff_second_currency_amount", + "created_on": "2025-01-21T07:39:19.742136", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6125, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "6df1995a-6c14-4ee7-8ff3-f2c6ac55fc0a", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.748019", + "column_name": "debt_balance_subpos_exch_diff_local_currency_amount", + "created_on": "2025-01-21T07:39:19.748015", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6126, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "b6d7b80f-3de7-488e-af1c-a493c8fd2284", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.753719", + "column_name": "exchange_diff_second_local_currency_amount", + "created_on": "2025-01-21T07:39:19.753715", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6127, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "63fc210e-698f-4e98-8cbb-422670352723", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.759229", + "column_name": "exchange_diff_local_currency_amount", + "created_on": "2025-01-21T07:39:19.759226", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6128, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "7cb066e8-03db-4d61-96d7-4410f4bdbd8b", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.764849", + "column_name": "fiscal_year_of_relevant_invoice", + "created_on": "2025-01-21T07:39:19.764846", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6129, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "60331127-d94b-42c5-99e8-6d43df3b2d89", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.770381", + "column_name": "position_number_of_relevant_invoice", + "created_on": "2025-01-21T07:39:19.770377", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6130, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "5b5e4c62-bef3-4fe6-858a-4126a76f2911", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.775983", + "column_name": "second_local_currency_amount", + "created_on": "2025-01-21T07:39:19.775980", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6131, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "eafe6175-ed93-4956-8364-d5d53f24df99", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.781895", + "column_name": "reverse_document_fiscal_year", + "created_on": "2025-01-21T07:39:19.781892", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6132, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "7d25dac3-4c00-4d1f-92e8-c561d3ef8dcd", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.787579", + "column_name": "final_position_line_item", + "created_on": "2025-01-21T07:39:19.787576", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6133, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "72f35f38-0d35-4a5a-a891-44723e45623b", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.793153", + "column_name": "final_fiscal_year", + "created_on": "2025-01-21T07:39:19.793150", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6134, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(Float64)", + "type_generic": null, + "uuid": "ee7bce0d-21fd-46f4-9fc1-c0d55a2570a6", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.798856", + "column_name": "is_second_friday", + "created_on": "2025-01-21T07:39:19.798853", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6135, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(UInt8)", + "type_generic": 0, + "uuid": "27332942-0b3e-45b9-b7bb-7673ebfe9834", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.804709", + "column_name": "deleted_flag", + "created_on": "2025-01-21T07:39:19.804706", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6136, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(UInt8)", + "type_generic": 0, + "uuid": "482d2726-0ac0-4d96-bdb4-c85fee7686ad", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.810182", + "column_name": "dttm_updated", + "created_on": "2025-01-21T07:39:19.810179", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6137, + "is_active": true, + "is_dttm": true, + "python_date_format": null, + "type": "Nullable(DateTime)", + "type_generic": 2, + "uuid": "965ab287-be1d-4ccf-9499-aa756e8369a4", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.815930", + "column_name": "filter_date", + "created_on": "2025-01-21T07:39:19.815927", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6138, + "is_active": true, + "is_dttm": true, + "python_date_format": null, + "type": "Nullable(DateTime)", + "type_generic": 2, + "uuid": "f96faa56-24ef-4a32-ab7d-a8264d84dc7e", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.821669", + "column_name": "dttm_inserted", + "created_on": "2025-01-21T07:39:19.821666", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6139, + "is_active": true, + "is_dttm": true, + "python_date_format": null, + "type": "Nullable(DateTime)", + "type_generic": 2, + "uuid": "4e46e715-ff45-4d6d-a9fb-e6dc28fdac08", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.827220", + "column_name": "dt_external_contract", + "created_on": "2025-01-21T07:39:19.827217", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6140, + "is_active": true, + "is_dttm": true, + "python_date_format": null, + "type": "Nullable(Date)", + "type_generic": 2, + "uuid": "6f0d0df8-d030-4816-913f-24d4f507106b", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.832814", + "column_name": "is_fns_restriction_list_exist", + "created_on": "2025-01-21T07:39:19.832810", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6141, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "cee2b0e4-aa7d-472e-a9a3-4c098defd74d", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.838345", + "column_name": "is_debt_daily_calculated", + "created_on": "2025-01-21T07:39:19.838342", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6142, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "10655321-340f-468a-8f98-8d9c3cc5366d", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.843929", + "column_name": "unit_balance_code_name", + "created_on": "2025-01-21T07:39:19.843926", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6143, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "c7bcedbd-023d-411d-9647-c60f10c99325", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.849508", + "column_name": "special_general_ledger_indicator", + "created_on": "2025-01-21T07:39:19.849505", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6144, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "a0446f99-c57c-4567-8bf0-a287739fa38f", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.855021", + "column_name": "is_group_company_affiliated", + "created_on": "2025-01-21T07:39:19.855017", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6145, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "55e45259-1b95-4351-b79f-a8cf3480a46a", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.860821", + "column_name": "is_related_party_rsbo", + "created_on": "2025-01-21T07:39:19.860818", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6146, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "233e7779-b7d1-4621-81ff-b30365b0123c", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.867178", + "column_name": "final_accounting_document_code", + "created_on": "2025-01-21T07:39:19.867175", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6147, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "13ced870-020d-4f8f-abc9-b3e6cf2de5f8", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.873085", + "column_name": "is_related_party_tco", + "created_on": "2025-01-21T07:39:19.873082", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6148, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "603dc460-9136-49ac-899c-bf5a39cb2c15", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.878895", + "column_name": "counterparty_search_name", + "created_on": "2025-01-21T07:39:19.878892", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6149, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "52229979-269c-4125-93b3-9edd45f23282", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.884623", + "column_name": "counterparty_truncated_code", + "created_on": "2025-01-21T07:39:19.884620", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6150, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "fc3e15b7-07a8-460a-9776-e114ffc40c70", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.890220", + "column_name": "reason_for_reversal", + "created_on": "2025-01-21T07:39:19.890217", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6151, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "cc96a82d-a5b8-414b-9de7-5c94300af04a", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.895874", + "column_name": "counterparty_mdm_code", + "created_on": "2025-01-21T07:39:19.895871", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6152, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "6a7c4678-d93f-45f2-b4cf-cc030e35eda1", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.901564", + "column_name": "counterparty_hfm_code", + "created_on": "2025-01-21T07:39:19.901561", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6153, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "0fc8b3f9-8951-4d39-8dc7-4c553bd50b66", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.907254", + "column_name": "counterparty_tin_code", + "created_on": "2025-01-21T07:39:19.907251", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6154, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "a6054696-1a78-4c0b-813d-5d7b97d4be00", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.912884", + "column_name": "is_lawsuit_exist", + "created_on": "2025-01-21T07:39:19.912881", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6155, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "ba499f9a-5db4-48dc-9ec3-29b36233845f", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.918722", + "column_name": "invoice_document_code", + "created_on": "2025-01-21T07:39:19.918719", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6156, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "c36da610-62b7-4df6-96e3-3a591b72cf56", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.924283", + "column_name": "is_bankrupt", + "created_on": "2025-01-21T07:39:19.924280", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6157, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "7ef7ad98-88ed-47c0-ab53-411fb51d260a", + "verbose_name": null + }, + { + "advanced_data_type": null, + "changed_on": "2025-01-21T07:39:19.929991", + "column_name": "counterparty_code", + "created_on": "2025-01-21T07:39:19.929988", + "description": null, + "expression": null, + "extra": "{\"warning_markdown\":null}", + "filterable": true, + "groupby": true, + "id": 6158, + "is_active": true, + "is_dttm": false, + "python_date_format": null, + "type": "Nullable(String)", + "type_generic": 1, + "uuid": "ffeff11a-5bf3-4eab-a381-08799c59f1bf", + "verbose_name": null + } + ], + "created_by": { + "first_name": "Андрей", + "last_name": "Волобуев" + }, + "created_on": "2025-01-21T07:39:19.316583", + "created_on_humanized": "8 месяцев назад", + "currency_formats": {}, + "database": { + "backend": "clickhousedb", + "database_name": "Dev Clickhouse", + "id": 19 + }, + "datasource_name": "FI-0022 Штрафы ПДЗ (click)", + "datasource_type": "table", + "default_endpoint": null, + "description": null, + "extra": null, + "fetch_values_predicate": null, + "filter_select_enabled": true, + "granularity_sqla": [ + [ + "dt_overdue", + "dt_overdue" + ], + [ + "dt_baseline_due_date_calculation", + "dt_baseline_due_date_calculation" + ], + [ + "dt_debt", + "dt_debt" + ], + [ + "dt_accounting_document", + "dt_accounting_document" + ], + [ + "dt_clearing", + "dt_clearing" + ], + [ + "dt", + "dt" + ], + [ + "dttm_updated", + "dttm_updated" + ], + [ + "filter_date", + "filter_date" + ], + [ + "dttm_inserted", + "dttm_inserted" + ], + [ + "dt_external_contract", + "dt_external_contract" + ] + ], + "id": 100, + "is_managed_externally": false, + "is_sqllab_view": false, + "kind": "virtual", + "main_dttm_col": null, + "metrics": [ + { + "changed_on": "2025-01-21T07:39:19.356732", + "created_on": "2025-01-21T07:39:19.356729", + "currency": null, + "d3format": null, + "description": null, + "expression": "SUM(\ndebt_subposition_document_currency_amount\n)", + "extra": "{\"warning_markdown\":\"\"}", + "id": 269, + "metric_name": "penalty_vd", + "metric_type": null, + "verbose_name": "Штрафы (ВД)", + "warning_text": null + }, + { + "changed_on": "2025-01-21T07:39:19.350535", + "created_on": "2025-01-21T07:39:19.350532", + "currency": null, + "d3format": null, + "description": null, + "expression": "SUM(\ndebt_subposition_local_currency_amount\n)", + "extra": "{\"warning_markdown\":\"\"}", + "id": 268, + "metric_name": "penalty_vv", + "metric_type": null, + "verbose_name": "Штрафы (ВВ)", + "warning_text": null + }, + { + "changed_on": "2025-01-21T07:39:19.344771", + "created_on": "2025-01-21T07:39:19.344768", + "currency": null, + "d3format": null, + "description": null, + "expression": "SUM(\ndebt_balance_subposition_usd_amount\n)", + "extra": "{\"warning_markdown\":\"\"}", + "id": 267, + "metric_name": "penalty_usd", + "metric_type": null, + "verbose_name": "Штрафы (USD)", + "warning_text": null + }, + { + "changed_on": "2025-01-21T07:39:19.337884", + "created_on": "2025-01-21T07:39:19.337881", + "currency": null, + "d3format": null, + "description": null, + "expression": "SUM(\ndebt_subposition_second_local_currency_amount\n)", + "extra": "{\"warning_markdown\":\"\"}", + "id": 266, + "metric_name": "penalty_vv2", + "metric_type": null, + "verbose_name": "Штрафы (ВВ2)", + "warning_text": null + } + ], + "name": "dm.FI-0022 Штрафы ПДЗ (click)", + "normalize_columns": false, + "offset": 0, + "order_by_choices": [ + [ + "[\"account_type\", true]", + "account_type По возрастанию" + ], + [ + "[\"account_type\", false]", + "account_type По убыванию" + ], + [ + "[\"accounting_document_code\", true]", + "accounting_document_code По возрастанию" + ], + [ + "[\"accounting_document_code\", false]", + "accounting_document_code По убыванию" + ], + [ + "[\"accounting_document_status_code\", true]", + "accounting_document_status_code По возрастанию" + ], + [ + "[\"accounting_document_status_code\", false]", + "accounting_document_status_code По убыванию" + ], + [ + "[\"accounting_document_type\", true]", + "accounting_document_type По возрастанию" + ], + [ + "[\"accounting_document_type\", false]", + "accounting_document_type По убыванию" + ], + [ + "[\"assignment_number\", true]", + "assignment_number По возрастанию" + ], + [ + "[\"assignment_number\", false]", + "assignment_number По убыванию" + ], + [ + "[\"budget_subtype_code\", true]", + "budget_subtype_code По возрастанию" + ], + [ + "[\"budget_subtype_code\", false]", + "budget_subtype_code По убыванию" + ], + [ + "[\"clearing_document_code\", true]", + "clearing_document_code По возрастанию" + ], + [ + "[\"clearing_document_code\", false]", + "clearing_document_code По убыванию" + ], + [ + "[\"contract_number\", true]", + "contract_number По возрастанию" + ], + [ + "[\"contract_number\", false]", + "contract_number По убыванию" + ], + [ + "[\"contract_supervisor_employee_number\", true]", + "contract_supervisor_employee_number По возрастанию" + ], + [ + "[\"contract_supervisor_employee_number\", false]", + "contract_supervisor_employee_number По убыванию" + ], + [ + "[\"contract_supervisor_name\", true]", + "contract_supervisor_name По возрастанию" + ], + [ + "[\"contract_supervisor_name\", false]", + "contract_supervisor_name По убыванию" + ], + [ + "[\"contract_trader_code\", true]", + "contract_trader_code По возрастанию" + ], + [ + "[\"contract_trader_code\", false]", + "contract_trader_code По убыванию" + ], + [ + "[\"contract_trader_name\", true]", + "contract_trader_name По возрастанию" + ], + [ + "[\"contract_trader_name\", false]", + "contract_trader_name По убыванию" + ], + [ + "[\"counterparty_code\", true]", + "counterparty_code По возрастанию" + ], + [ + "[\"counterparty_code\", false]", + "counterparty_code По убыванию" + ], + [ + "[\"counterparty_full_name\", true]", + "counterparty_full_name По возрастанию" + ], + [ + "[\"counterparty_full_name\", false]", + "counterparty_full_name По убыванию" + ], + [ + "[\"counterparty_hfm_code\", true]", + "counterparty_hfm_code По возрастанию" + ], + [ + "[\"counterparty_hfm_code\", false]", + "counterparty_hfm_code По убыванию" + ], + [ + "[\"counterparty_mdm_code\", true]", + "counterparty_mdm_code По возрастанию" + ], + [ + "[\"counterparty_mdm_code\", false]", + "counterparty_mdm_code По убыванию" + ], + [ + "[\"counterparty_search_name\", true]", + "counterparty_search_name По возрастанию" + ], + [ + "[\"counterparty_search_name\", false]", + "counterparty_search_name По убыванию" + ], + [ + "[\"counterparty_tin_code\", true]", + "counterparty_tin_code По возрастанию" + ], + [ + "[\"counterparty_tin_code\", false]", + "counterparty_tin_code По убыванию" + ], + [ + "[\"counterparty_truncated_code\", true]", + "counterparty_truncated_code По возрастанию" + ], + [ + "[\"counterparty_truncated_code\", false]", + "counterparty_truncated_code По убыванию" + ], + [ + "[\"country_code\", true]", + "country_code По возрастанию" + ], + [ + "[\"country_code\", false]", + "country_code По убыванию" + ], + [ + "[\"debit_or_credit\", true]", + "debit_or_credit По возрастанию" + ], + [ + "[\"debit_or_credit\", false]", + "debit_or_credit По убыванию" + ], + [ + "[\"debt_balance_document_currency_amount\", true]", + "debt_balance_document_currency_amount По возрастанию" + ], + [ + "[\"debt_balance_document_currency_amount\", false]", + "debt_balance_document_currency_amount По убыванию" + ], + [ + "[\"debt_balance_exchange_diff_local_currency_amount\", true]", + "debt_balance_exchange_diff_local_currency_amount По возрастанию" + ], + [ + "[\"debt_balance_exchange_diff_local_currency_amount\", false]", + "debt_balance_exchange_diff_local_currency_amount По убыванию" + ], + [ + "[\"debt_balance_exchange_diff_second_local_currency_amount\", true]", + "debt_balance_exchange_diff_second_local_currency_amount По возрастанию" + ], + [ + "[\"debt_balance_exchange_diff_second_local_currency_amount\", false]", + "debt_balance_exchange_diff_second_local_currency_amount По убыванию" + ], + [ + "[\"debt_balance_local_currency_amount\", true]", + "debt_balance_local_currency_amount По возрастанию" + ], + [ + "[\"debt_balance_local_currency_amount\", false]", + "debt_balance_local_currency_amount По убыванию" + ], + [ + "[\"debt_balance_second_local_currency_amount\", true]", + "debt_balance_second_local_currency_amount По возрастанию" + ], + [ + "[\"debt_balance_second_local_currency_amount\", false]", + "debt_balance_second_local_currency_amount По убыванию" + ], + [ + "[\"debt_balance_subpos_exch_diff_local_currency_amount\", true]", + "debt_balance_subpos_exch_diff_local_currency_amount По возрастанию" + ], + [ + "[\"debt_balance_subpos_exch_diff_local_currency_amount\", false]", + "debt_balance_subpos_exch_diff_local_currency_amount По убыванию" + ], + [ + "[\"debt_balance_subpos_exch_diff_second_local_curr_amount\", true]", + "debt_balance_subpos_exch_diff_second_local_curr_amount По возрастанию" + ], + [ + "[\"debt_balance_subpos_exch_diff_second_local_curr_amount\", false]", + "debt_balance_subpos_exch_diff_second_local_curr_amount По убыванию" + ], + [ + "[\"debt_balance_subpos_second_local_currency_amount_reval\", true]", + "debt_balance_subpos_second_local_currency_amount_reval По возрастанию" + ], + [ + "[\"debt_balance_subpos_second_local_currency_amount_reval\", false]", + "debt_balance_subpos_second_local_currency_amount_reval По убыванию" + ], + [ + "[\"debt_balance_subposition_document_currency_amount\", true]", + "debt_balance_subposition_document_currency_amount По возрастанию" + ], + [ + "[\"debt_balance_subposition_document_currency_amount\", false]", + "debt_balance_subposition_document_currency_amount По убыванию" + ], + [ + "[\"debt_balance_subposition_local_currency_amount\", true]", + "debt_balance_subposition_local_currency_amount По возрастанию" + ], + [ + "[\"debt_balance_subposition_local_currency_amount\", false]", + "debt_balance_subposition_local_currency_amount По убыванию" + ], + [ + "[\"debt_balance_subposition_second_local_currency_amount\", true]", + "debt_balance_subposition_second_local_currency_amount По возрастанию" + ], + [ + "[\"debt_balance_subposition_second_local_currency_amount\", false]", + "debt_balance_subposition_second_local_currency_amount По убыванию" + ], + [ + "[\"debt_balance_subposition_usd_amount\", true]", + "debt_balance_subposition_usd_amount По возрастанию" + ], + [ + "[\"debt_balance_subposition_usd_amount\", false]", + "debt_balance_subposition_usd_amount По убыванию" + ], + [ + "[\"debt_balance_with_revaluation_diff_second_currency_amount\", true]", + "debt_balance_with_revaluation_diff_second_currency_amount По возрастанию" + ], + [ + "[\"debt_balance_with_revaluation_diff_second_currency_amount\", false]", + "debt_balance_with_revaluation_diff_second_currency_amount По убыванию" + ], + [ + "[\"debt_period_group\", true]", + "debt_period_group По возрастанию" + ], + [ + "[\"debt_period_group\", false]", + "debt_period_group По убыванию" + ], + [ + "[\"debt_subposition_document_currency_amount\", true]", + "debt_subposition_document_currency_amount По возрастанию" + ], + [ + "[\"debt_subposition_document_currency_amount\", false]", + "debt_subposition_document_currency_amount По убыванию" + ], + [ + "[\"debt_subposition_local_currency_amount\", true]", + "debt_subposition_local_currency_amount По возрастанию" + ], + [ + "[\"debt_subposition_local_currency_amount\", false]", + "debt_subposition_local_currency_amount По убыванию" + ], + [ + "[\"debt_subposition_number\", true]", + "debt_subposition_number По возрастанию" + ], + [ + "[\"debt_subposition_number\", false]", + "debt_subposition_number По убыванию" + ], + [ + "[\"debt_subposition_second_local_currency_amount\", true]", + "debt_subposition_second_local_currency_amount По возрастанию" + ], + [ + "[\"debt_subposition_second_local_currency_amount\", false]", + "debt_subposition_second_local_currency_amount По убыванию" + ], + [ + "[\"deleted_flag\", true]", + "deleted_flag По возрастанию" + ], + [ + "[\"deleted_flag\", false]", + "deleted_flag По убыванию" + ], + [ + "[\"document_currency_amount\", true]", + "document_currency_amount По возрастанию" + ], + [ + "[\"document_currency_amount\", false]", + "document_currency_amount По убыванию" + ], + [ + "[\"document_currency_code\", true]", + "document_currency_code По возрастанию" + ], + [ + "[\"document_currency_code\", false]", + "document_currency_code По убыванию" + ], + [ + "[\"dt\", true]", + "dt По возрастанию" + ], + [ + "[\"dt\", false]", + "dt По убыванию" + ], + [ + "[\"dt_accounting_document\", true]", + "dt_accounting_document По возрастанию" + ], + [ + "[\"dt_accounting_document\", false]", + "dt_accounting_document По убыванию" + ], + [ + "[\"dt_baseline_due_date_calculation\", true]", + "dt_baseline_due_date_calculation По возрастанию" + ], + [ + "[\"dt_baseline_due_date_calculation\", false]", + "dt_baseline_due_date_calculation По убыванию" + ], + [ + "[\"dt_clearing\", true]", + "dt_clearing По возрастанию" + ], + [ + "[\"dt_clearing\", false]", + "dt_clearing По убыванию" + ], + [ + "[\"dt_debt\", true]", + "dt_debt По возрастанию" + ], + [ + "[\"dt_debt\", false]", + "dt_debt По убыванию" + ], + [ + "[\"dt_external_contract\", true]", + "dt_external_contract По возрастанию" + ], + [ + "[\"dt_external_contract\", false]", + "dt_external_contract По убыванию" + ], + [ + "[\"dt_overdue\", true]", + "dt_overdue По возрастанию" + ], + [ + "[\"dt_overdue\", false]", + "dt_overdue По убыванию" + ], + [ + "[\"dttm_inserted\", true]", + "dttm_inserted По возрастанию" + ], + [ + "[\"dttm_inserted\", false]", + "dttm_inserted По убыванию" + ], + [ + "[\"dttm_updated\", true]", + "dttm_updated По возрастанию" + ], + [ + "[\"dttm_updated\", false]", + "dttm_updated По убыванию" + ], + [ + "[\"exchange_diff_local_currency_amount\", true]", + "exchange_diff_local_currency_amount По возрастанию" + ], + [ + "[\"exchange_diff_local_currency_amount\", false]", + "exchange_diff_local_currency_amount По убыванию" + ], + [ + "[\"exchange_diff_second_local_currency_amount\", true]", + "exchange_diff_second_local_currency_amount По возрастанию" + ], + [ + "[\"exchange_diff_second_local_currency_amount\", false]", + "exchange_diff_second_local_currency_amount По убыванию" + ], + [ + "[\"external_contract_number\", true]", + "external_contract_number По возрастанию" + ], + [ + "[\"external_contract_number\", false]", + "external_contract_number По убыванию" + ], + [ + "[\"filter_date\", true]", + "filter_date По возрастанию" + ], + [ + "[\"filter_date\", false]", + "filter_date По убыванию" + ], + [ + "[\"final_accounting_document_code\", true]", + "final_accounting_document_code По возрастанию" + ], + [ + "[\"final_accounting_document_code\", false]", + "final_accounting_document_code По убыванию" + ], + [ + "[\"final_fiscal_year\", true]", + "final_fiscal_year По возрастанию" + ], + [ + "[\"final_fiscal_year\", false]", + "final_fiscal_year По убыванию" + ], + [ + "[\"final_position_line_item\", true]", + "final_position_line_item По возрастанию" + ], + [ + "[\"final_position_line_item\", false]", + "final_position_line_item По убыванию" + ], + [ + "[\"fiscal_year\", true]", + "fiscal_year По возрастанию" + ], + [ + "[\"fiscal_year\", false]", + "fiscal_year По убыванию" + ], + [ + "[\"fiscal_year_of_relevant_invoice\", true]", + "fiscal_year_of_relevant_invoice По возрастанию" + ], + [ + "[\"fiscal_year_of_relevant_invoice\", false]", + "fiscal_year_of_relevant_invoice По убыванию" + ], + [ + "[\"funds_center_code\", true]", + "funds_center_code По возрастанию" + ], + [ + "[\"funds_center_code\", false]", + "funds_center_code По убыванию" + ], + [ + "[\"funds_center_name\", true]", + "funds_center_name По возрастанию" + ], + [ + "[\"funds_center_name\", false]", + "funds_center_name По убыванию" + ], + [ + "[\"general_ledger_account_code\", true]", + "general_ledger_account_code По возрастанию" + ], + [ + "[\"general_ledger_account_code\", false]", + "general_ledger_account_code По убыванию" + ], + [ + "[\"general_ledger_account_full_name\", true]", + "general_ledger_account_full_name По возрастанию" + ], + [ + "[\"general_ledger_account_full_name\", false]", + "general_ledger_account_full_name По убыванию" + ], + [ + "[\"invoice_document_code\", true]", + "invoice_document_code По возрастанию" + ], + [ + "[\"invoice_document_code\", false]", + "invoice_document_code По убыванию" + ], + [ + "[\"is_bankrupt\", true]", + "is_bankrupt По возрастанию" + ], + [ + "[\"is_bankrupt\", false]", + "is_bankrupt По убыванию" + ], + [ + "[\"is_debt_daily_calculated\", true]", + "is_debt_daily_calculated По возрастанию" + ], + [ + "[\"is_debt_daily_calculated\", false]", + "is_debt_daily_calculated По убыванию" + ], + [ + "[\"is_fns_restriction_list_exist\", true]", + "is_fns_restriction_list_exist По возрастанию" + ], + [ + "[\"is_fns_restriction_list_exist\", false]", + "is_fns_restriction_list_exist По убыванию" + ], + [ + "[\"is_group_company_affiliated\", true]", + "is_group_company_affiliated По возрастанию" + ], + [ + "[\"is_group_company_affiliated\", false]", + "is_group_company_affiliated По убыванию" + ], + [ + "[\"is_lawsuit_exist\", true]", + "is_lawsuit_exist По возрастанию" + ], + [ + "[\"is_lawsuit_exist\", false]", + "is_lawsuit_exist По убыванию" + ], + [ + "[\"is_related_party_rsbo\", true]", + "is_related_party_rsbo По возрастанию" + ], + [ + "[\"is_related_party_rsbo\", false]", + "is_related_party_rsbo По убыванию" + ], + [ + "[\"is_related_party_tco\", true]", + "is_related_party_tco По возрастанию" + ], + [ + "[\"is_related_party_tco\", false]", + "is_related_party_tco По убыванию" + ], + [ + "[\"is_second_friday\", true]", + "is_second_friday По возрастанию" + ], + [ + "[\"is_second_friday\", false]", + "is_second_friday По убыванию" + ], + [ + "[\"local_currency_amount\", true]", + "local_currency_amount По возрастанию" + ], + [ + "[\"local_currency_amount\", false]", + "local_currency_amount По убыванию" + ], + [ + "[\"local_currency_code\", true]", + "local_currency_code По возрастанию" + ], + [ + "[\"local_currency_code\", false]", + "local_currency_code По убыванию" + ], + [ + "[\"plant_code\", true]", + "plant_code По возрастанию" + ], + [ + "[\"plant_code\", false]", + "plant_code По убыванию" + ], + [ + "[\"plant_code-plant_name\", true]", + "plant_code-plant_name По возрастанию" + ], + [ + "[\"plant_code-plant_name\", false]", + "plant_code-plant_name По убыванию" + ], + [ + "[\"plant_name\", true]", + "plant_name По возрастанию" + ], + [ + "[\"plant_name\", false]", + "plant_name По убыванию" + ], + [ + "[\"position_line_item\", true]", + "position_line_item По возрастанию" + ], + [ + "[\"position_line_item\", false]", + "position_line_item По убыванию" + ], + [ + "[\"position_line_item_text\", true]", + "position_line_item_text По возрастанию" + ], + [ + "[\"position_line_item_text\", false]", + "position_line_item_text По убыванию" + ], + [ + "[\"position_number_of_relevant_invoice\", true]", + "position_number_of_relevant_invoice По возрастанию" + ], + [ + "[\"position_number_of_relevant_invoice\", false]", + "position_number_of_relevant_invoice По убыванию" + ], + [ + "[\"purchase_or_sales_group_code\", true]", + "purchase_or_sales_group_code По возрастанию" + ], + [ + "[\"purchase_or_sales_group_code\", false]", + "purchase_or_sales_group_code По убыванию" + ], + [ + "[\"purchase_or_sales_group_name\", true]", + "purchase_or_sales_group_name По возрастанию" + ], + [ + "[\"purchase_or_sales_group_name\", false]", + "purchase_or_sales_group_name По убыванию" + ], + [ + "[\"reason_for_reversal\", true]", + "reason_for_reversal По возрастанию" + ], + [ + "[\"reason_for_reversal\", false]", + "reason_for_reversal По убыванию" + ], + [ + "[\"reference_document_number\", true]", + "reference_document_number По возрастанию" + ], + [ + "[\"reference_document_number\", false]", + "reference_document_number По убыванию" + ], + [ + "[\"responsibility_center_code\", true]", + "responsibility_center_code По возрастанию" + ], + [ + "[\"responsibility_center_code\", false]", + "responsibility_center_code По убыванию" + ], + [ + "[\"responsibility_center_level1_code\", true]", + "responsibility_center_level1_code По возрастанию" + ], + [ + "[\"responsibility_center_level1_code\", false]", + "responsibility_center_level1_code По убыванию" + ], + [ + "[\"responsibility_center_level1_name\", true]", + "responsibility_center_level1_name По возрастанию" + ], + [ + "[\"responsibility_center_level1_name\", false]", + "responsibility_center_level1_name По убыванию" + ], + [ + "[\"responsibility_center_name\", true]", + "responsibility_center_name По возрастанию" + ], + [ + "[\"responsibility_center_name\", false]", + "responsibility_center_name По убыванию" + ], + [ + "[\"reverse_document_code\", true]", + "reverse_document_code По возрастанию" + ], + [ + "[\"reverse_document_code\", false]", + "reverse_document_code По убыванию" + ], + [ + "[\"reverse_document_fiscal_year\", true]", + "reverse_document_fiscal_year По возрастанию" + ], + [ + "[\"reverse_document_fiscal_year\", false]", + "reverse_document_fiscal_year По убыванию" + ], + [ + "[\"second_local_currency_amount\", true]", + "second_local_currency_amount По возрастанию" + ], + [ + "[\"second_local_currency_amount\", false]", + "second_local_currency_amount По убыванию" + ], + [ + "[\"second_local_currency_code\", true]", + "second_local_currency_code По возрастанию" + ], + [ + "[\"second_local_currency_code\", false]", + "second_local_currency_code По убыванию" + ], + [ + "[\"special_general_ledger_indicator\", true]", + "special_general_ledger_indicator По возрастанию" + ], + [ + "[\"special_general_ledger_indicator\", false]", + "special_general_ledger_indicator По убыванию" + ], + [ + "[\"tax_code\", true]", + "tax_code По возрастанию" + ], + [ + "[\"tax_code\", false]", + "tax_code По убыванию" + ], + [ + "[\"terms_of_payment_code\", true]", + "terms_of_payment_code По возрастанию" + ], + [ + "[\"terms_of_payment_code\", false]", + "terms_of_payment_code По убыванию" + ], + [ + "[\"terms_of_payment_name\", true]", + "terms_of_payment_name По возрастанию" + ], + [ + "[\"terms_of_payment_name\", false]", + "terms_of_payment_name По убыванию" + ], + [ + "[\"unit_balance_code\", true]", + "unit_balance_code По возрастанию" + ], + [ + "[\"unit_balance_code\", false]", + "unit_balance_code По убыванию" + ], + [ + "[\"unit_balance_code_name\", true]", + "unit_balance_code_name По возрастанию" + ], + [ + "[\"unit_balance_code_name\", false]", + "unit_balance_code_name По убыванию" + ], + [ + "[\"unit_balance_name\", true]", + "unit_balance_name По возрастанию" + ], + [ + "[\"unit_balance_name\", false]", + "unit_balance_name По убыванию" + ] + ], + "owners": [ + { + "first_name": "Андрей", + "id": 10, + "last_name": "Волобуев" + }, + { + "first_name": "admin", + "id": 9, + "last_name": "admin" + } + ], + "schema": "dm", + "select_star": "SELECT *\nFROM `dm`.`FI-0022 Штрафы ПДЗ (click)`\nLIMIT 100", + "sql": "select t1.*,\ncase \n when \"dt\" <= \"dt_overdue\" then '0. Дебиторская задолженность'\n when \"dt_overdue\" is null then '0. Дебиторская задолженность'\n when \"dt\" - \"dt_overdue\" between 0 and 5 then '1. ПДЗ до 5 дней'\n when \"dt\" - \"dt_overdue\" between 6 and 15 then '2. ПДЗ до 15 дней'\n when \"dt\" - \"dt_overdue\" between 16 and 30 then '3. ПДЗ до 30 дней'\n when \"dt\" - \"dt_overdue\" between 31 and 60 then '4. ПДЗ до 60 дней'\n when \"dt\" - \"dt_overdue\" between 61 and 90 then '5. ПДЗ до 90 дней'\n when \"dt\" - \"dt_overdue\" > 90 then '6. ПДЗ больше 90 дней'\n\nend as debt_period_group,\nif(is_debt_daily_calculated IS NULL, t1.dt, (now() - INTERVAL 1 DAY)) AS filter_date,\n plant_code || ' ' || plant_name AS \"plant_code-plant_name\",\n unit_balance_code || ' ' || unit_balance_name AS unit_balance_code_name\nfrom\ndm.account_debt_penalty t1\n LEFT JOIN dm.counterparty_td ctd\n ON t1.counterparty_code = ctd.counterparty_code\nwhere ctd.is_deleted IS NULL", + "table_name": "FI-0022 Штрафы ПДЗ (click)", + "template_params": null, + "time_grain_sqla": [ + [ + "PT1M", + "Минута" + ], + [ + "PT5M", + "5 минут" + ], + [ + "PT10M", + "10 минут" + ], + [ + "PT15M", + "15 минут" + ], + [ + "PT30M", + "30 минут" + ], + [ + "PT1H", + "Час" + ], + [ + "P1D", + "День" + ], + [ + "P1W", + "Неделя" + ], + [ + "P1M", + "Месяц" + ], + [ + "P3M", + "Квартал" + ], + [ + "P1Y", + "Год" + ] + ], + "uid": "100__table", + "url": "/tablemodelview/edit/100", + "verbose_map": { + "__timestamp": "Time", + "account_type": "Вид счета", + "accounting_document_code": "Номер бухгалтерского документа", + "accounting_document_status_code": "accounting_document_status_code", + "accounting_document_type": "Вид документа", + "assignment_number": "Номер присвоения", + "budget_subtype_code": "Подвид бюджета", + "clearing_document_code": "Номер документа выравнивания", + "contract_number": "Номер договора", + "contract_supervisor_employee_number": "Куратор договора, таб №", + "contract_supervisor_name": "Куратор договора, ФИО", + "contract_trader_code": "Табельный номер трейдера договора", + "contract_trader_name": "ФИО трейдера договора", + "counterparty_code": "counterparty_code", + "counterparty_full_name": "Наименование контрагента", + "counterparty_hfm_code": "counterparty_hfm_code", + "counterparty_mdm_code": "counterparty_mdm_code", + "counterparty_search_name": "counterparty_search_name", + "counterparty_tin_code": "counterparty_tin_code", + "counterparty_truncated_code": "counterparty_truncated_code", + "country_code": "Страна регистрации контрагента", + "debit_or_credit": "Д/К", + "debt_balance_document_currency_amount": "Остаток задолженности в валюте документа ", + "debt_balance_exchange_diff_local_currency_amount": "ВВ Курсовая разница остатка позиции", + "debt_balance_exchange_diff_second_local_currency_amount": "ВВ2 Курсовая разница остатка позиции", + "debt_balance_local_currency_amount": "Остаток задолженности в валюте организации", + "debt_balance_second_local_currency_amount": "Остаток задолженности во второй валюте", + "debt_balance_subpos_exch_diff_local_currency_amount": "debt_balance_subpos_exch_diff_local_currency_amount", + "debt_balance_subpos_exch_diff_second_local_curr_amount": "debt_balance_subpos_exch_diff_second_local_curr_amount", + "debt_balance_subpos_second_local_currency_amount_reval": "debt_balance_subpos_second_local_currency_amount_reval", + "debt_balance_subposition_document_currency_amount": "Остаток КЗ по данной позиции, в валюте документа", + "debt_balance_subposition_local_currency_amount": "Остаток КЗ по данной позиции, в валюте БЕ", + "debt_balance_subposition_second_local_currency_amount": "Остаток КЗ по данной позиции, во второй валюте", + "debt_balance_subposition_usd_amount": "Сумма задолженности подпозиции в USD", + "debt_balance_with_revaluation_diff_second_currency_amount": "debt_balance_with_revaluation_diff_second_currency_amount", + "debt_period_group": "Период ПДЗ", + "debt_subposition_document_currency_amount": "Сумма задолженности подпозиции в валюте документа", + "debt_subposition_local_currency_amount": "Сумма задолженности подпозиции в местной валюте", + "debt_subposition_number": "Номер подпозиции задолженности", + "debt_subposition_second_local_currency_amount": "Сумма задолженности подпозиции во второй местной валюте", + "deleted_flag": "deleted_flag", + "document_currency_amount": "Сумма в валюте документа", + "document_currency_code": "Код валюты документа", + "dt": "Дата", + "dt_accounting_document": "Дата документа", + "dt_baseline_due_date_calculation": "Базовая дата для расчета срока оплаты", + "dt_clearing": "Дата выравнивания", + "dt_debt": "Дата возникновения задолженности ", + "dt_external_contract": "dt_external_contract", + "dt_overdue": "Дата, когда задолженность станет просроченной ", + "dttm_inserted": "dttm_inserted", + "dttm_updated": "dttm_updated", + "exchange_diff_local_currency_amount": "exchange_diff_local_currency_amount", + "exchange_diff_second_local_currency_amount": "exchange_diff_second_local_currency_amount", + "external_contract_number": "Внешний номер договора", + "filter_date": "filter_date", + "final_accounting_document_code": "final_accounting_document_code", + "final_fiscal_year": "final_fiscal_year", + "final_position_line_item": "final_position_line_item", + "fiscal_year": "Фин. год.", + "fiscal_year_of_relevant_invoice": "fiscal_year_of_relevant_invoice", + "funds_center_code": "Подразделение финансового менеджмента, код", + "funds_center_name": "Подразделение финансового менеджмента, название", + "general_ledger_account_code": "Основной счет главной книги ", + "general_ledger_account_full_name": "Подробный текст к основному счету на русском", + "invoice_document_code": "invoice_document_code", + "is_bankrupt": "is_bankrupt", + "is_debt_daily_calculated": "is_debt_daily_calculated", + "is_fns_restriction_list_exist": "is_fns_restriction_list_exist", + "is_group_company_affiliated": "is_group_company_affiliated", + "is_lawsuit_exist": "is_lawsuit_exist", + "is_related_party_rsbo": "is_related_party_rsbo", + "is_related_party_tco": "is_related_party_tco", + "is_second_friday": "is_second_friday", + "local_currency_amount": "local_currency_amount", + "local_currency_code": "Код внутренней валюты", + "penalty_usd": "Штрафы (USD)", + "penalty_vd": "Штрафы (ВД)", + "penalty_vv": "Штрафы (ВВ)", + "penalty_vv2": "Штрафы (ВВ2)", + "plant_code": "Завод", + "plant_code-plant_name": "plant_code-plant_name", + "plant_name": "Название филиала", + "position_line_item": "Номер строки проводки в рамках бухгалтерского документа ", + "position_line_item_text": "Текст к позиции", + "position_number_of_relevant_invoice": "position_number_of_relevant_invoice", + "purchase_or_sales_group_code": "Группа закупок/сбыта, Код", + "purchase_or_sales_group_name": "Группа закупок/сбыта, Наименование", + "reason_for_reversal": "reason_for_reversal", + "reference_document_number": "Ссылочный номер документа ", + "responsibility_center_code": "Центр ответственности, код", + "responsibility_center_level1_code": "responsibility_center_level1_code", + "responsibility_center_level1_name": "responsibility_center_level1_name", + "responsibility_center_name": "Центр ответственности, наименование", + "reverse_document_code": "№ документа сторно ", + "reverse_document_fiscal_year": "reverse_document_fiscal_year", + "second_local_currency_amount": "second_local_currency_amount", + "second_local_currency_code": "Код второй внутренней валюты", + "special_general_ledger_indicator": "special_general_ledger_indicator", + "tax_code": "Код налога с оборота", + "terms_of_payment_code": "Код условий платежа", + "terms_of_payment_name": "Наименование условия платежа", + "unit_balance_code": "Балансовая единица", + "unit_balance_code_name": "unit_balance_code_name", + "unit_balance_name": "Название БЕ" + } + } +} \ No newline at end of file diff --git a/tech_spec/Пример PUT.md b/tech_spec/Пример PUT.md new file mode 100644 index 0000000..73d32a9 --- /dev/null +++ b/tech_spec/Пример PUT.md @@ -0,0 +1,57 @@ +put /api/v1/dataset/{pk} + +{ + "cache_timeout": 0, + "columns": [ + { + "advanced_data_type": "string", + "column_name": "string", + "description": "string", + "expression": "string", + "extra": "string", + "filterable": true, + "groupby": true, + "id": 0, + "is_active": true, + "is_dttm": true, + "python_date_format": "string", + "type": "string", + "uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "verbose_name": "string" + } + ], + "database_id": 0, + "default_endpoint": "string", + "description": "string", + "external_url": "string", + "extra": "string", + "fetch_values_predicate": "string", + "filter_select_enabled": true, + "is_managed_externally": true, + "is_sqllab_view": true, + "main_dttm_col": "string", + "metrics": [ + { + "currency": "string", + "d3format": "string", + "description": "string", + "expression": "string", + "extra": "string", + "id": 0, + "metric_name": "string", + "metric_type": "string", + "uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "verbose_name": "string", + "warning_text": "string" + } + ], + "normalize_columns": true, + "offset": 0, + "owners": [ + 0 + ], + "schema": "string", + "sql": "string", + "table_name": "string", + "template_params": "string" +} \ No newline at end of file From 6be572ac67ca6fc3071cd352b7fb56c1060d0053 Mon Sep 17 00:00:00 2001 From: Volobuev Andrey Date: Tue, 7 Oct 2025 14:33:28 +0300 Subject: [PATCH 2/4] column mapper --- superset_tool/utils/dataset_mapper.py | 230 ++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 superset_tool/utils/dataset_mapper.py diff --git a/superset_tool/utils/dataset_mapper.py b/superset_tool/utils/dataset_mapper.py new file mode 100644 index 0000000..8973aef --- /dev/null +++ b/superset_tool/utils/dataset_mapper.py @@ -0,0 +1,230 @@ +# +# @SEMANTICS: dataset, mapping, postgresql, xlsx, superset +# @PURPOSE: Этот модуль отвечает за обновление метаданных (verbose_map) в датасетах Superset, извлекая их из PostgreSQL или XLSX-файлов. +# @DEPENDS_ON: superset_tool.client -> Использует SupersetClient для взаимодействия с API. +# @DEPENDS_ON: pandas -> для чтения XLSX-файлов. +# @DEPENDS_ON: psycopg2 -> для подключения к PostgreSQL. + +# +import pandas as pd +import psycopg2 +from superset_tool.client import SupersetClient +from superset_tool.utils.init_clients import setup_clients +from superset_tool.utils.logger import SupersetLogger +from typing import Dict, List, Optional, Any +# + +# --- Начало кода модуля --- + +# +# @PURPOSE: Класс для меппинга и обновления verbose_map в датасетах Superset. +class DatasetMapper: + def __init__(self, logger: SupersetLogger): + self.logger = logger + + # + # @PURPOSE: Извлекает комментарии к колонкам из системного каталога PostgreSQL. + # @PRE: `db_config` должен содержать валидные креды для подключения к PostgreSQL. + # @PRE: `table_name` и `table_schema` должны быть строками. + # @POST: Возвращается словарь с меппингом `column_name` -> `column_comment`. + # @PARAM: db_config: Dict - Конфигурация для подключения к БД. + # @PARAM: table_name: str - Имя таблицы. + # @PARAM: table_schema: str - Схема таблицы. + # @RETURN: Dict[str, str] - Словарь с комментариями к колонкам. + # @THROW: Exception - При ошибках подключения или выполнения запроса к БД. + def get_postgres_comments(self, db_config: Dict, table_name: str, table_schema: str) -> Dict[str, str]: + self.logger.info("[get_postgres_comments][Enter] Fetching comments from PostgreSQL for %s.%s.", table_schema, table_name) + query = f""" + SELECT + cols.column_name, + CASE + WHEN pg_catalog.col_description( + (SELECT c.oid + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = cols.table_name + AND n.nspname = cols.table_schema), + cols.ordinal_position::int + ) LIKE '%|%' THEN + split_part( + pg_catalog.col_description( + (SELECT c.oid + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = cols.table_name + AND n.nspname = cols.table_schema), + cols.ordinal_position::int + ), + '|', + 1 + ) + ELSE + pg_catalog.col_description( + (SELECT c.oid + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = cols.table_name + AND n.nspname = cols.table_schema), + cols.ordinal_position::int + ) + END AS column_comment + FROM + information_schema.columns cols + WHERE cols.table_catalog = '{db_config.get('dbname')}' AND cols.table_name = '{table_name}' AND cols.table_schema = '{table_schema}'; + """ + comments = {} + try: + with psycopg2.connect(**db_config) as conn, conn.cursor() as cursor: + cursor.execute(query) + for row in cursor.fetchall(): + if row[1]: + comments[row[0]] = row[1] + self.logger.info("[get_postgres_comments][Success] Fetched %d comments.", len(comments)) + except Exception as e: + self.logger.error("[get_postgres_comments][Failure] %s", e, exc_info=True) + raise + return comments + # + + # + # @PURPOSE: Загружает меппинги 'column_name' -> 'column_comment' из XLSX файла. + # @PRE: `file_path` должен быть валидным путем к XLSX файлу с колонками 'column_name' и 'column_comment'. + # @POST: Возвращается словарь с меппингами. + # @PARAM: file_path: str - Путь к XLSX файлу. + # @RETURN: Dict[str, str] - Словарь с меппингами. + # @THROW: Exception - При ошибках чтения файла или парсинга. + def load_excel_mappings(self, file_path: str) -> Dict[str, str]: + self.logger.info("[load_excel_mappings][Enter] Loading mappings from %s.", file_path) + try: + df = pd.read_excel(file_path) + mappings = df.set_index('column_name')['column_comment'].to_dict() + self.logger.info("[load_excel_mappings][Success] Loaded %d mappings.", len(mappings)) + return mappings + except Exception as e: + self.logger.error("[load_excel_mappings][Failure] %s", e, exc_info=True) + raise + # + + # + # @PURPOSE: Основная функция для выполнения меппинга и обновления verbose_map датасета в Superset. + # @PARAM: superset_client: SupersetClient - Клиент Superset. + # @PARAM: dataset_id: int - ID датасета для обновления. + # @PARAM: source: str - Источник данных ('postgres', 'excel', 'both'). + # @PARAM: postgres_config: Optional[Dict] - Конфигурация для подключения к PostgreSQL. + # @PARAM: excel_path: Optional[str] - Путь к XLSX файлу. + # @PARAM: table_name: Optional[str] - Имя таблицы в PostgreSQL. + # @PARAM: table_schema: Optional[str] - Схема таблицы в PostgreSQL. + # @RELATION: CALLS -> self.get_postgres_comments + # @RELATION: CALLS -> self.load_excel_mappings + # @RELATION: CALLS -> superset_client.get_dataset + # @RELATION: CALLS -> superset_client.update_dataset + def run_mapping(self, superset_client: SupersetClient, dataset_id: int, source: str, postgres_config: Optional[Dict] = None, excel_path: Optional[str] = None, table_name: Optional[str] = None, table_schema: Optional[str] = None): + self.logger.info("[run_mapping][Enter] Starting dataset mapping for ID %d from source '%s'.", dataset_id, source) + mappings: Dict[str, str] = {} + + try: + if source in ['postgres', 'both']: + assert postgres_config and table_name and table_schema, "Postgres config is required." + mappings.update(self.get_postgres_comments(postgres_config, table_name, table_schema)) + if source in ['excel', 'both']: + assert excel_path, "Excel path is required." + mappings.update(self.load_excel_mappings(excel_path)) + if source not in ['postgres', 'excel', 'both']: + self.logger.error("[run_mapping][Failure] Invalid source: %s.", source) + return + + dataset_response = superset_client.get_dataset(dataset_id) + dataset_data = dataset_response['result'] + + original_columns = dataset_data.get('columns', []) + updated_columns = [] + changes_made = False + + for column in original_columns: + col_name = column.get('column_name') + + new_column = { + "column_name": col_name, + "id": column.get("id"), + "advanced_data_type": column.get("advanced_data_type"), + "description": column.get("description"), + "expression": column.get("expression"), + "extra": column.get("extra"), + "filterable": column.get("filterable"), + "groupby": column.get("groupby"), + "is_active": column.get("is_active"), + "is_dttm": column.get("is_dttm"), + "python_date_format": column.get("python_date_format"), + "type": column.get("type"), + "uuid": column.get("uuid"), + "verbose_name": column.get("verbose_name"), + } + + new_column = {k: v for k, v in new_column.items() if v is not None} + + if col_name in mappings: + mapping_value = mappings[col_name] + if isinstance(mapping_value, str) and new_column.get('verbose_name') != mapping_value: + new_column['verbose_name'] = mapping_value + changes_made = True + + updated_columns.append(new_column) + + updated_metrics = [] + for metric in dataset_data.get("metrics", []): + new_metric = { + "id": metric.get("id"), + "metric_name": metric.get("metric_name"), + "expression": metric.get("expression"), + "verbose_name": metric.get("verbose_name"), + "description": metric.get("description"), + "d3format": metric.get("d3format"), + "currency": metric.get("currency"), + "extra": metric.get("extra"), + "warning_text": metric.get("warning_text"), + "metric_type": metric.get("metric_type"), + "uuid": metric.get("uuid"), + } + updated_metrics.append({k: v for k, v in new_metric.items() if v is not None}) + + if changes_made: + payload_for_update = { + "database_id": dataset_data.get("database", {}).get("id"), + "table_name": dataset_data.get("table_name"), + "schema": dataset_data.get("schema"), + "columns": updated_columns, + "owners": [owner["id"] for owner in dataset_data.get("owners", [])], + "metrics": updated_metrics, + "extra": dataset_data.get("extra"), + "description": dataset_data.get("description"), + "sql": dataset_data.get("sql"), + "cache_timeout": dataset_data.get("cache_timeout"), + "catalog": dataset_data.get("catalog"), + "default_endpoint": dataset_data.get("default_endpoint"), + "external_url": dataset_data.get("external_url"), + "fetch_values_predicate": dataset_data.get("fetch_values_predicate"), + "filter_select_enabled": dataset_data.get("filter_select_enabled"), + "is_managed_externally": dataset_data.get("is_managed_externally"), + "is_sqllab_view": dataset_data.get("is_sqllab_view"), + "main_dttm_col": dataset_data.get("main_dttm_col"), + "normalize_columns": dataset_data.get("normalize_columns"), + "offset": dataset_data.get("offset"), + "template_params": dataset_data.get("template_params"), + } + + payload_for_update = {k: v for k, v in payload_for_update.items() if v is not None} + + superset_client.update_dataset(dataset_id, payload_for_update) + self.logger.info("[run_mapping][Success] Dataset %d columns' verbose_name updated.", dataset_id) + else: + self.logger.info("[run_mapping][State] No changes in columns' verbose_name, skipping update.") + + except (AssertionError, FileNotFoundError, Exception) as e: + self.logger.error("[run_mapping][Failure] %s", e, exc_info=True) + return + # +# + +# --- Конец кода модуля --- + +# \ No newline at end of file From 373ed59dce846b59533f9fa904f7eb46fc34dc84 Mon Sep 17 00:00:00 2001 From: Volobuev Andrey Date: Tue, 7 Oct 2025 14:33:54 +0300 Subject: [PATCH 3/4] mapper --- dataset_mapper.py | 131 --------------------------------------- get_dataset_structure.py | 69 +++++++++++++++++++++ run_mapper.py | 2 +- 3 files changed, 70 insertions(+), 132 deletions(-) delete mode 100644 dataset_mapper.py create mode 100644 get_dataset_structure.py diff --git a/dataset_mapper.py b/dataset_mapper.py deleted file mode 100644 index 09cee40..0000000 --- a/dataset_mapper.py +++ /dev/null @@ -1,131 +0,0 @@ -# -# @SEMANTICS: dataset, mapping, postgresql, xlsx, superset -# @PURPOSE: Этот модуль отвечает за обновление метаданных (verbose_map) в датасетах Superset, извлекая их из PostgreSQL или XLSX-файлов. -# @DEPENDS_ON: superset_tool.client -> Использует SupersetClient для взаимодействия с API. -# @DEPENDS_ON: pandas -> для чтения XLSX-файлов. -# @DEPENDS_ON: psycopg2 -> для подключения к PostgreSQL. - -# -import pandas as pd -import psycopg2 -from superset_tool.client import SupersetClient -from superset_tool.utils.init_clients import setup_clients -from superset_tool.utils.logger import SupersetLogger -from typing import Dict, List, Optional, Any -# - -# --- Начало кода модуля --- - -# -# @PURPOSE: Класс для меппинга и обновления verbose_map в датасетах Superset. -class DatasetMapper: - def __init__(self, logger: SupersetLogger): - self.logger = logger - - # - # @PURPOSE: Извлекает комментарии к колонкам из системного каталога PostgreSQL. - # @PRE: `db_config` должен содержать валидные креды для подключения к PostgreSQL. - # @PRE: `table_name` и `table_schema` должны быть строками. - # @POST: Возвращается словарь с меппингом `column_name` -> `column_comment`. - # @PARAM: db_config: Dict - Конфигурация для подключения к БД. - # @PARAM: table_name: str - Имя таблицы. - # @PARAM: table_schema: str - Схема таблицы. - # @RETURN: Dict[str, str] - Словарь с комментариями к колонкам. - # @THROW: Exception - При ошибках подключения или выполнения запроса к БД. - def get_postgres_comments(self, db_config: Dict, table_name: str, table_schema: str) -> Dict[str, str]: - self.logger.info("[get_postgres_comments][Enter] Fetching comments from PostgreSQL for %s.%s.", table_schema, table_name) - query = f""" - SELECT cols.column_name, pg_catalog.col_description(c.oid, cols.ordinal_position::int) AS column_comment - FROM information_schema.columns cols - JOIN pg_catalog.pg_class c ON c.relname = cols.table_name - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace AND n.nspname = cols.table_schema - WHERE cols.table_catalog = '{db_config.get('dbname')}' AND cols.table_name = '{table_name}' AND cols.table_schema = '{table_schema}'; - """ - comments = {} - try: - with psycopg2.connect(**db_config) as conn, conn.cursor() as cursor: - cursor.execute(query) - for row in cursor.fetchall(): - if row[1]: - comments[row[0]] = row[1] - self.logger.info("[get_postgres_comments][Success] Fetched %d comments.", len(comments)) - except Exception as e: - self.logger.error("[get_postgres_comments][Failure] %s", e, exc_info=True) - raise - return comments - # - - # - # @PURPOSE: Загружает меппинги 'column_name' -> 'column_comment' из XLSX файла. - # @PRE: `file_path` должен быть валидным путем к XLSX файлу с колонками 'column_name' и 'column_comment'. - # @POST: Возвращается словарь с меппингами. - # @PARAM: file_path: str - Путь к XLSX файлу. - # @RETURN: Dict[str, str] - Словарь с меппингами. - # @THROW: Exception - При ошибках чтения файла или парсинга. - def load_excel_mappings(self, file_path: str) -> Dict[str, str]: - self.logger.info("[load_excel_mappings][Enter] Loading mappings from %s.", file_path) - try: - df = pd.read_excel(file_path) - mappings = df.set_index('column_name')['column_comment'].to_dict() - self.logger.info("[load_excel_mappings][Success] Loaded %d mappings.", len(mappings)) - return mappings - except Exception as e: - self.logger.error("[load_excel_mappings][Failure] %s", e, exc_info=True) - raise - # - - # - # @PURPOSE: Основная функция для выполнения меппинга и обновления verbose_map датасета в Superset. - # @PARAM: superset_client: SupersetClient - Клиент Superset. - # @PARAM: dataset_id: int - ID датасета для обновления. - # @PARAM: source: str - Источник данных ('postgres', 'excel', 'both'). - # @PARAM: postgres_config: Optional[Dict] - Конфигурация для подключения к PostgreSQL. - # @PARAM: excel_path: Optional[str] - Путь к XLSX файлу. - # @PARAM: table_name: Optional[str] - Имя таблицы в PostgreSQL. - # @PARAM: table_schema: Optional[str] - Схема таблицы в PostgreSQL. - # @RELATION: CALLS -> self.get_postgres_comments - # @RELATION: CALLS -> self.load_excel_mappings - # @RELATION: CALLS -> superset_client.get_dataset - # @RELATION: CALLS -> superset_client.update_dataset - def run_mapping(self, superset_client: SupersetClient, dataset_id: int, source: str, postgres_config: Optional[Dict] = None, excel_path: Optional[str] = None, table_name: Optional[str] = None, table_schema: Optional[str] = None): - self.logger.info("[run_mapping][Enter] Starting dataset mapping for ID %d from source '%s'.", dataset_id, source) - mappings: Dict[str, str] = {} - - try: - if source in ['postgres', 'both']: - assert postgres_config and table_name and table_schema, "Postgres config is required." - mappings.update(self.get_postgres_comments(postgres_config, table_name, table_schema)) - if source in ['excel', 'both']: - assert excel_path, "Excel path is required." - mappings.update(self.load_excel_mappings(excel_path)) - if source not in ['postgres', 'excel', 'both']: - self.logger.error("[run_mapping][Failure] Invalid source: %s.", source) - return - - dataset_response = superset_client.get_dataset(dataset_id) - dataset_data = dataset_response['result'] - - original_verbose_map = dataset_data.get('verbose_map', {}).copy() - new_verbose_map = original_verbose_map.copy() - - for column in dataset_data.get('columns', []): - column_name = column.get('column_name') - if column_name in mappings: - new_verbose_map[column_name] = mappings[column_name] - - if original_verbose_map != new_verbose_map: - dataset_data['verbose_map'] = new_verbose_map - superset_client.update_dataset(dataset_id, {'verbose_map': new_verbose_map}) - self.logger.info("[run_mapping][Success] Dataset %d verbose_map updated.", dataset_id) - else: - self.logger.info("[run_mapping][State] No changes in verbose_map, skipping update.") - - except (AssertionError, FileNotFoundError, Exception) as e: - self.logger.error("[run_mapping][Failure] %s", e, exc_info=True) - return - # -# - -# --- Конец кода модуля --- - -# \ No newline at end of file diff --git a/get_dataset_structure.py b/get_dataset_structure.py new file mode 100644 index 0000000..4c17045 --- /dev/null +++ b/get_dataset_structure.py @@ -0,0 +1,69 @@ +# +# @SEMANTICS: superset, dataset, structure, debug, json +# @PURPOSE: Этот модуль предназначен для получения и сохранения структуры данных датасета из Superset. Он используется для отладки и анализа данных, возвращаемых API. +# @DEPENDS_ON: superset_tool.client -> Использует SupersetClient для взаимодействия с API. +# @DEPENDS_ON: superset_tool.utils.init_clients -> Для инициализации клиентов Superset. +# @DEPENDS_ON: superset_tool.utils.logger -> Для логирования. + +# +import argparse +import json +from superset_tool.utils.init_clients import setup_clients +from superset_tool.utils.logger import SupersetLogger +# + +# --- Начало кода модуля --- + +# +# @PURPOSE: Получает структуру датасета из Superset и сохраняет ее в JSON-файл. +# @PARAM: env: str - Среда (dev, prod, и т.д.) для подключения. +# @PARAM: dataset_id: int - ID датасета для получения. +# @PARAM: output_path: str - Путь для сохранения JSON-файла. +# @RELATION: CALLS -> setup_clients +# @RELATION: CALLS -> superset_client.get_dataset +def get_and_save_dataset(env: str, dataset_id: int, output_path: str): + """ + Получает структуру датасета и сохраняет в файл. + """ + logger = SupersetLogger(name="DatasetStructureRetriever") + logger.info("[get_and_save_dataset][Enter] Starting to fetch dataset structure for ID %d from env '%s'.", dataset_id, env) + + try: + clients = setup_clients(logger=logger) + superset_client = clients.get(env) + if not superset_client: + logger.error("[get_and_save_dataset][Failure] Environment '%s' not found.", env) + return + + dataset_response = superset_client.get_dataset(dataset_id) + dataset_data = dataset_response.get('result') + + if not dataset_data: + logger.error("[get_and_save_dataset][Failure] No result in dataset response.") + return + + with open(output_path, 'w', encoding='utf-8') as f: + json.dump(dataset_data, f, ensure_ascii=False, indent=4) + + logger.info("[get_and_save_dataset][Success] Dataset structure saved to %s.", output_path) + + except Exception as e: + logger.error("[get_and_save_dataset][Failure] An error occurred: %s", e, exc_info=True) + +# + +# +# @PURPOSE: Точка входа для CLI. Парсит аргументы и запускает получение структуры датасета. +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Получение структуры датасета из Superset.") + parser.add_argument("--dataset-id", required=True, type=int, help="ID датасета.") + parser.add_argument("--env", required=True, help="Среда для подключения (например, dev).") + parser.add_argument("--output-path", default="dataset_structure.json", help="Путь для сохранения JSON-файла.") + args = parser.parse_args() + + get_and_save_dataset(args.env, args.dataset_id, args.output_path) +# + +# --- Конец кода модуля --- + +# \ No newline at end of file diff --git a/run_mapper.py b/run_mapper.py index fbf9ebb..99d405a 100644 --- a/run_mapper.py +++ b/run_mapper.py @@ -8,7 +8,7 @@ import argparse from superset_tool.utils.init_clients import setup_clients from superset_tool.utils.logger import SupersetLogger -from dataset_mapper import DatasetMapper +from superset_tool.utils.dataset_mapper import DatasetMapper # # --- Начало кода модуля --- From 37c73a86b671f5527dda289d99009757ca6a11fd Mon Sep 17 00:00:00 2001 From: Volobuev Andrey Date: Tue, 7 Oct 2025 17:39:42 +0300 Subject: [PATCH 4/4] update Readme --- README.md | 42 ++++++++++++++++++++------- superset_tool/utils/dataset_mapper.py | 2 +- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 85ca7e2..076f552 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +Вот обновлённый README с информацией о работе со скриптами: + # Инструменты автоматизации Superset ## Обзор @@ -9,6 +11,7 @@ - `backup_script.py`: Основной скрипт для выполнения запланированного резервного копирования дашбордов Superset. - `migration_script.py`: Основной скрипт для переноса конкретных дашбордов между окружениями, включая переопределение соединений с базами данных. - `search_script.py`: Скрипт для поиска данных во всех доступных датасетах на сервере +- `run_mapper.py`: CLI-скрипт для маппинга метаданных датасетов. - `superset_tool/`: - `client.py`: Python-клиент для взаимодействия с API Superset. - `exceptions.py`: Пользовательские классы исключений для структурированной обработки ошибок. @@ -17,6 +20,8 @@ - `fileio.py`: Утилиты для работы с файловой системой (работа с архивами, парсинг YAML). - `logger.py`: Конфигурация логгера для единообразного логирования в проекте. - `network.py`: HTTP-клиент для сетевых запросов с обработкой аутентификации и повторных попыток. + - `init_clients.py`: Утилита для инициализации клиентов Superset для разных окружений. + - `dataset_mapper.py`: Логика маппинга метаданных датасетов. ## Настройка @@ -66,17 +71,34 @@ python migration_script.py `from_c` и `to_c`. ### Скрипт поиска (`search_script.py`) -Строка для поиска и клиенты для поиска задаются здесь -# Поиск всех таблиц в датасете -```python -results = search_datasets( - client=clients['dev'], - search_pattern=r'dm_view\.account_debt', - search_fields=["sql"], - logger=logger -) +Для поиска по текстовым паттернам в метаданных датасетов Superset: +```bash +python search_script.py ``` +Скрипт использует регулярные выражения для поиска в полях датасетов, таких как SQL-запросы. Результаты поиска выводятся в лог и в консоль. +### Скрипт маппинга метаданных (`run_mapper.py`) +Для обновления метаданных датасета (например, verbose names) в Superset: +```bash +python run_mapper.py --source --dataset-id [--table-name ] [--table-schema ] [--excel-path ] [--env ] +``` +Если вы используете XLSX - файл должен содержать два столбца - column_name | verbose_name + + +Параметры: +- `--source`: Источник данных ('postgres', 'excel' или 'both'). +- `--dataset-id`: ID датасета для обновления. +- `--table-name`: Имя таблицы для PostgreSQL. +- `--table-schema`: Схема таблицы для PostgreSQL. +- `--excel-path`: Путь к Excel-файлу. +- `--env`: Окружение Superset ('dev', 'prod' и т.д.). + +Пример использования: +```bash +python run_mapper.py --source postgres --dataset-id 123 --table-name account_debt --table-schema dm_view --env dev + +python run_mapper.py --source=excel --dataset-id=286 --excel-path=H:\dev\ss-tools\286_map.xlsx --env=dev +``` ## Логирование Логи пишутся в файл в директории `Logs` (например, `P:\Superset\010 Бекапы\Logs` для резервных копий) и выводятся в консоль. Уровень логирования по умолчанию — `INFO`. @@ -90,4 +112,4 @@ results = search_datasets( --- [COHERENCE_CHECK_PASSED] README.md создан и согласован с модулями. -Перевод выполнен с сохранением оригинальной Markdown-разметки и стиля документа. [1] \ No newline at end of file +Перевод выполнен с сохранением оригинальной Markdown-разметки и стиля документа. \ No newline at end of file diff --git a/superset_tool/utils/dataset_mapper.py b/superset_tool/utils/dataset_mapper.py index 8973aef..44f9311 100644 --- a/superset_tool/utils/dataset_mapper.py +++ b/superset_tool/utils/dataset_mapper.py @@ -97,7 +97,7 @@ class DatasetMapper: self.logger.info("[load_excel_mappings][Enter] Loading mappings from %s.", file_path) try: df = pd.read_excel(file_path) - mappings = df.set_index('column_name')['column_comment'].to_dict() + mappings = df.set_index('column_name')['verbose_name'].to_dict() self.logger.info("[load_excel_mappings][Success] Loaded %d mappings.", len(mappings)) return mappings except Exception as e: