add clean folders in backup

This commit is contained in:
Volobuev Andrey
2025-06-30 13:09:25 +03:00
parent 7b68b2c8cc
commit 79984ab56b
2 changed files with 144 additions and 30 deletions

View File

@@ -24,7 +24,7 @@ import keyring
from superset_tool.models import SupersetConfig from superset_tool.models import SupersetConfig
from superset_tool.client import SupersetClient from superset_tool.client import SupersetClient
from superset_tool.utils.logger import SupersetLogger from superset_tool.utils.logger import SupersetLogger
from superset_tool.utils.fileio import save_and_unpack_dashboard, archive_exports, sanitize_filename from superset_tool.utils.fileio import save_and_unpack_dashboard, archive_exports, sanitize_filename,consolidate_archive_folders,remove_empty_directories
# [COHERENCE_CHECK_PASSED] Все необходимые модули импортированы и согласованы. # [COHERENCE_CHECK_PASSED] Все необходимые модули импортированы и согласованы.
# [FUNCTION] setup_clients # [FUNCTION] setup_clients
@@ -92,25 +92,35 @@ def setup_clients(logger: SupersetLogger):
raise raise
# [FUNCTION] backup_dashboards # [FUNCTION] backup_dashboards
# @contract: Выполняет бэкап всех доступных дашбордов для заданного клиента и окружения. def backup_dashboards(client: SupersetClient,
# @pre: env_name: str,
# - `client` должен быть инициализированным экземпляром `SupersetClient`. backup_root: Path,
# - `env_name` должен быть строкой, обозначающей окружение. logger: SupersetLogger,
# - `backup_root` должен быть валидным путем к корневой директории бэкапа. consolidate: bool = True,
# - `logger` должен быть инициализирован. rotate_archive: bool = True,
# @post: clean_folders:bool = True) -> bool:
# - Дашборды экспортируются и сохраняются в поддиректориях `backup_root/env_name/dashboard_title`. """ [CONTRACT] Выполняет бэкап всех доступных дашбордов для заданного клиента и окружения.
# - Старые экспорты архивируются. @pre:
# - Возвращает `True` если все дашборды были экспортированы без критических ошибок, `False` иначе. - `client` должен быть инициализированным экземпляром `SupersetClient`.
# @side_effects: - `env_name` должен быть строкой, обозначающей окружение.
# - Создает директории и файлы в файловой системе. - `backup_root` должен быть валидным путем к корневой директории бэкапа.
# - Логирует статус выполнения, успешные экспорты и ошибки. - `logger` должен быть инициализирован.
# @exceptions: @post:
# - `SupersetAPIError`, `NetworkError`, `DashboardNotFoundError`, `ExportError` могут быть подняты методами `SupersetClient` и будут логированы. - Дашборды экспортируются и сохраняются в поддиректориях `backup_root/env_name/dashboard_title`.
def backup_dashboards(client: SupersetClient, env_name: str, backup_root: Path, logger: SupersetLogger) -> bool: - Старые экспорты архивируются.
"""Выполнение бэкапа дашбордов с детальным логированием ошибок""" - Возвращает `True` если все дашборды были экспортированы без критических ошибок, `False` иначе.
@side_effects:
- Создает директории и файлы в файловой системе.
- Логирует статус выполнения, успешные экспорты и ошибки.
@exceptions:
- `SupersetAPIError`, `NetworkError`, `DashboardNotFoundError`, `ExportError` могут быть подняты методами `SupersetClient` и будут логированы."""
# [ANCHOR] DASHBOARD_BACKUP_PROCESS # [ANCHOR] DASHBOARD_BACKUP_PROCESS
logger.info(f"[INFO] Запуск бэкапа дашбордов для окружения: {env_name}") logger.info(f"[INFO] Запуск бэкапа дашбордов для окружения: {env_name}")
logger.debug(
"[PARAMS] Флаги: consolidate=%s, rotate_archive=%s, clean_folders=%s",
consolidate, rotate_archive, clean_folders,
extra={"env": env_name} # контекст для логирования
)
try: try:
dashboard_count, dashboard_meta = client.get_dashboards() dashboard_count, dashboard_meta = client.get_dashboards()
logger.info(f"[INFO] Найдено {dashboard_count} дашбордов для экспорта в {env_name}") logger.info(f"[INFO] Найдено {dashboard_count} дашбордов для экспорта в {env_name}")
@@ -139,7 +149,7 @@ def backup_dashboards(client: SupersetClient, env_name: str, backup_root: Path,
try: try:
# [ANCHOR] CREATE_DASHBOARD_DIR # [ANCHOR] CREATE_DASHBOARD_DIR
# Используем slug в пути для большей уникальности и избежания конфликтов имен # Используем slug в пути для большей уникальности и избежания конфликтов имен
dashboard_base_dir_name = sanitize_filename(f"{dashboard_slug}-{dashboard_title}") dashboard_base_dir_name = sanitize_filename(f"{dashboard_title}")
dashboard_dir = backup_root / env_name / dashboard_base_dir_name dashboard_dir = backup_root / env_name / dashboard_base_dir_name
dashboard_dir.mkdir(parents=True, exist_ok=True) dashboard_dir.mkdir(parents=True, exist_ok=True)
logger.debug(f"[DEBUG] Директория для дашборда: {dashboard_dir}") logger.debug(f"[DEBUG] Директория для дашборда: {dashboard_dir}")
@@ -158,18 +168,20 @@ def backup_dashboards(client: SupersetClient, env_name: str, backup_root: Path,
) )
logger.info(f"[INFO] Дашборд '{dashboard_title}' (ID: {dashboard_id}) успешно экспортирован.") logger.info(f"[INFO] Дашборд '{dashboard_title}' (ID: {dashboard_id}) успешно экспортирован.")
if rotate_archive:
# [ANCHOR] ARCHIVE_OLD_BACKUPS # [ANCHOR] ARCHIVE_OLD_BACKUPS
try: try:
archive_exports(dashboard_dir, logger=logger) archive_exports(dashboard_dir, logger=logger)
logger.debug(f"[DEBUG] Старые экспорты для '{dashboard_title}' архивированы.") logger.debug(f"[DEBUG] Старые экспорты для '{dashboard_title}' архивированы.")
except Exception as cleanup_error: except Exception as cleanup_error:
logger.warning( logger.warning(
f"[WARN] Ошибка архивирования старых бэкапов для '{dashboard_title}': {cleanup_error}", f"[WARN] Ошибка архивирования старых бэкапов для '{dashboard_title}': {cleanup_error}",
exc_info=False # Не показываем полный traceback для очистки, т.к. это второстепенно exc_info=False # Не показываем полный traceback для очистки, т.к. это второстепенно
) )
success_count += 1 success_count += 1
except Exception as db_error: except Exception as db_error:
error_info = { error_info = {
'dashboard_id': dashboard_id, 'dashboard_id': dashboard_id,
@@ -184,6 +196,28 @@ def backup_dashboards(client: SupersetClient, env_name: str, backup_root: Path,
extra=error_info, exc_info=True # Логируем полный traceback для ошибок экспорта extra=error_info, exc_info=True # Логируем полный traceback для ошибок экспорта
) )
if consolidate:
# [ANCHOR] Объединяем архивы по SLUG в одну папку с максимальной датой
try:
consolidate_archive_folders(backup_root / env_name , logger=logger)
logger.debug(f"[DEBUG] Файлы для '{dashboard_title}' консолидированы.")
except Exception as consolidate_error:
logger.warning(
f"[WARN] Ошибка консолидации файлов для '{backup_root / env_name}': {consolidate_error}",
exc_info=False # Не показываем полный traceback для консолидации, т.к. это второстепенно
)
if clean_folders:
# [ANCHOR] Удаляем пустые папки
try:
dirs_count = remove_empty_directories(backup_root / env_name , logger=logger)
logger.debug(f"[DEBUG] {dirs_count} пустых папок в '{backup_root / env_name }' удалены.")
except Exception as clean_error:
logger.warning(
f"[WARN] Ошибка очистки пустых директорий в '{backup_root / env_name}': {clean_error}",
exc_info=False # Не показываем полный traceback для консолидации, т.к. это второстепенно
)
if error_details: if error_details:
logger.error( logger.error(
f"[COHERENCE_CHECK_FAILED] Итоги экспорта для {env_name}:", f"[COHERENCE_CHECK_FAILED] Итоги экспорта для {env_name}:",

View File

@@ -16,6 +16,7 @@ from pathlib import Path
from typing import Any, Optional, Tuple, Dict, List, Literal, Union, BinaryIO, LiteralString from typing import Any, Optional, Tuple, Dict, List, Literal, Union, BinaryIO, LiteralString
from collections import defaultdict from collections import defaultdict
from datetime import date from datetime import date
import glob
from contextlib import contextmanager from contextlib import contextmanager
# [IMPORTS] Third-party # [IMPORTS] Third-party
@@ -674,6 +675,85 @@ def update_yamls(
logger.error(f"[YAML_UPDATE_ERROR] Критическая ошибка: {str(e)}", exc_info=True) logger.error(f"[YAML_UPDATE_ERROR] Критическая ошибка: {str(e)}", exc_info=True)
raise raise
def consolidate_archive_folders(root_directory: Path, logger: Optional[SupersetLogger] = None) -> None:
"""
Consolidates dashboard folders under a root directory based on the slug (pattern MM-0080 - two latin letters - hyphen - 4 digits)
and moves the contents to the folder with the latest modification date.
Args:
root_directory (Path): The root directory containing the dashboard folders.
Raises:
TypeError: If root_directory is not a Path object.
ValueError: If root_directory is empty.
[CONTRACT]
@pre: root_directory must be a valid Path object representing an existing directory.
@post: The contents of all folders matching the slug pattern are moved to the folder with the latest modification date for each slug.
@invariant: The slug pattern remains consistent throughout the execution.
@raise: TypeError if root_directory is not a Path, ValueError if root_directory is empty.
"""
# [CONTRACT] Ensure valid input
if not isinstance(root_directory, Path):
raise TypeError("root_directory must be a Path object.")
if not root_directory:
raise ValueError("root_directory cannot be empty.")
logger.debug(f"[DEBUG] Checking root_folder: {root_directory}")
# [SECTION] Define the slug pattern
slug_pattern = re.compile(r"([A-Z]{2}-\d{4})") # Capture the first occurrence of the pattern
# [SECTION] Group folders by slug
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}") # Debug log: show the folder name being checked
match = slug_pattern.search(folder_name)
if match:
slug = match.group(1) # Extract the captured group (the slug)
logger.info(f"[INFO] Found slug: {slug} in folder: {folder_name}") #Log when slug is matched
logger.debug(f"[DEBUG] Regex match object: {match}") # Log the complete match object
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}") # Debug log: show when slug is not matched
else:
logger.debug(f"[DEBUG] Not a directory: {folder_name}") #debug log for when its not a directory
# [SECTION] Check if any slugs were found
if not dashboards_by_slug:
logger.warning("[WARN] No folders found matching the slug pattern.")
return
# [SECTION] Iterate through each slug group
for slug, folder_list in dashboards_by_slug.items():
# [ACTION] Find the folder with the latest modification date
latest_folder = max(folder_list, key=os.path.getmtime)
logger.info(f"[INFO] Latest folder for slug {slug}: {latest_folder}")
# [SECTION] Move contents of other folders to the latest folder
for folder in folder_list:
if folder != latest_folder:
# [ACTION] Move contents
try:
for item in os.listdir(folder):
s = os.path.join(folder, item)
d = os.path.join(latest_folder, item)
if os.path.isdir(s):
shutil.move(s, d)
else:
shutil.move(s, d)
logger.info(f"[INFO] Moved contents of {folder} to {latest_folder}")
except Exception as e:
logger.error(f"[ERROR] Failed to move contents of {folder} to {latest_folder}: {e}", exc_info=True)
logger.info("[INFO] Dashboard consolidation completed.")
# [COHERENCE_CHECK_PASSED] Function executed successfully and all contracts were met.
def sync_for_git( def sync_for_git(
source_path: str, source_path: str,
destination_path: str, destination_path: str,