chore(lint): apply ruff --fix (4443 auto-fixes)
Auto-fixed categories: - F401: unused imports removed - I001: import blocks sorted - W293: trailing whitespace stripped - UP035: deprecated typing imports replaced - SIM: simplify suggestions applied - ARG: unused args prefixed with underscore - T201: print statements removed - F841: unused variables removed - RUF059: unpacked variables prefixed Remaining ~1500 unfixable errors (C901, B904, N806, E402) require manual work. Backend smoke tests: 13/13 passed.
This commit is contained in:
@@ -6,24 +6,20 @@
|
||||
# @RELATION DEPENDS_ON -> superset_tool.utils
|
||||
# @RELATION USES -> TaskContext
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from ..core.logger import belief_scope
|
||||
from ..core.logger import logger as app_logger
|
||||
from ..core.plugin_base import PluginBase
|
||||
from ..core.logger import belief_scope, logger as app_logger
|
||||
from ..core.superset_client import SupersetClient
|
||||
from ..core.utils.network import SupersetAPIError
|
||||
from ..core.utils.fileio import (
|
||||
save_and_unpack_dashboard,
|
||||
archive_exports,
|
||||
sanitize_filename,
|
||||
consolidate_archive_folders,
|
||||
remove_empty_directories,
|
||||
RetentionPolicy
|
||||
)
|
||||
from ..dependencies import get_config_manager
|
||||
from ..core.task_manager.context import TaskContext
|
||||
from ..core.utils.fileio import RetentionPolicy, archive_exports, consolidate_archive_folders, remove_empty_directories, sanitize_filename, save_and_unpack_dashboard
|
||||
from ..core.utils.network import SupersetAPIError
|
||||
from ..dependencies import get_config_manager
|
||||
|
||||
|
||||
# #region BackupPlugin [TYPE Class]
|
||||
# @BRIEF Implementation of the backup plugin logic.
|
||||
@@ -90,12 +86,12 @@ class BackupPlugin(PluginBase):
|
||||
# @PRE: Plugin instance exists.
|
||||
# @POST: Returns dictionary schema.
|
||||
# @RETURN: Dict[str, Any] - JSON schema.
|
||||
def get_schema(self) -> Dict[str, Any]:
|
||||
def get_schema(self) -> dict[str, Any]:
|
||||
with belief_scope("get_schema"):
|
||||
config_manager = get_config_manager()
|
||||
envs = [e.name for e in config_manager.get_environments()]
|
||||
config_manager.get_config().settings.storage.root_path
|
||||
|
||||
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -116,58 +112,58 @@ class BackupPlugin(PluginBase):
|
||||
# @PARAM: context (Optional[TaskContext]) - Task context for logging with source attribution.
|
||||
# @PRE: Target environment must be configured. params must be a dictionary.
|
||||
# @POST: All dashboards are exported and archived.
|
||||
async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None):
|
||||
async def execute(self, params: dict[str, Any], context: TaskContext | None = None):
|
||||
with belief_scope("execute"):
|
||||
config_manager = get_config_manager()
|
||||
|
||||
|
||||
# Support both parameter names: environment_id (for task creation) and env (for direct calls)
|
||||
env_id = params.get("environment_id") or params.get("env")
|
||||
dashboard_ids = params.get("dashboard_ids") or params.get("dashboards")
|
||||
|
||||
|
||||
# Log the incoming parameters for debugging
|
||||
log = context.logger if context else app_logger
|
||||
log.info(f"Backup parameters received: env_id={env_id}, dashboard_ids={dashboard_ids}")
|
||||
|
||||
|
||||
# Resolve environment name if environment_id is provided
|
||||
if env_id:
|
||||
env_config = next((e for e in config_manager.get_environments() if e.id == env_id), None)
|
||||
if env_config:
|
||||
params["env"] = env_config.name
|
||||
|
||||
|
||||
env = params.get("env")
|
||||
if not env:
|
||||
raise KeyError("env")
|
||||
|
||||
|
||||
log.info(f"Backup started for environment: {env}, selected dashboards: {dashboard_ids}")
|
||||
|
||||
storage_settings = config_manager.get_config().settings.storage
|
||||
# Use 'backups' subfolder within the storage root
|
||||
backup_path = Path(storage_settings.root_path) / "backups"
|
||||
|
||||
|
||||
# Use TaskContext logger if available, otherwise fall back to app_logger
|
||||
log = context.logger if context else app_logger
|
||||
|
||||
|
||||
# Create sub-loggers for different components
|
||||
superset_log = log.with_source("superset_api") if context else log
|
||||
storage_log = log.with_source("storage") if context else log
|
||||
|
||||
|
||||
log.info(f"Starting backup for environment: {env}")
|
||||
|
||||
try:
|
||||
config_manager = get_config_manager()
|
||||
if not config_manager.has_environments():
|
||||
raise ValueError("No Superset environments configured. Please add an environment in Settings.")
|
||||
|
||||
|
||||
env_config = config_manager.get_environment(env)
|
||||
if not env_config:
|
||||
raise ValueError(f"Environment '{env}' not found in configuration.")
|
||||
|
||||
|
||||
client = SupersetClient(env_config)
|
||||
|
||||
|
||||
# Get all dashboards
|
||||
all_dashboard_count, all_dashboard_meta = client.get_dashboards()
|
||||
superset_log.info(f"Found {all_dashboard_count} total dashboards in environment")
|
||||
|
||||
|
||||
# Filter dashboards if specific IDs are provided
|
||||
if dashboard_ids:
|
||||
dashboard_ids_int = [int(did) for did in dashboard_ids]
|
||||
@@ -228,7 +224,7 @@ class BackupPlugin(PluginBase):
|
||||
"path": str(dashboard_dir)
|
||||
})
|
||||
|
||||
except (SupersetAPIError, RequestException, IOError, OSError) as db_error:
|
||||
except (SupersetAPIError, RequestException, OSError) as db_error:
|
||||
log.error(f"Failed to export dashboard {dashboard_title} (ID: {dashboard_id}): {db_error}")
|
||||
failed_dashboards.append({
|
||||
"id": dashboard_id,
|
||||
@@ -236,7 +232,7 @@ class BackupPlugin(PluginBase):
|
||||
"error": str(db_error)
|
||||
})
|
||||
continue
|
||||
|
||||
|
||||
consolidate_archive_folders(backup_path / env.upper())
|
||||
remove_empty_directories(str(backup_path / env.upper()))
|
||||
|
||||
@@ -252,7 +248,7 @@ class BackupPlugin(PluginBase):
|
||||
"failures": failed_dashboards
|
||||
}
|
||||
|
||||
except (RequestException, IOError, KeyError) as e:
|
||||
except (OSError, RequestException, KeyError) as e:
|
||||
log.error(f"Fatal error during backup for {env}: {e}")
|
||||
raise e
|
||||
# endregion execute
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
# @RELATION Inherits from PluginBase. Uses SupersetClient from core.
|
||||
# @RELATION USES -> TaskContext
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Any
|
||||
|
||||
from ..core.logger import belief_scope, logger
|
||||
from ..core.plugin_base import PluginBase
|
||||
from ..core.superset_client import SupersetClient
|
||||
from ..core.logger import logger, belief_scope
|
||||
from ..core.task_manager.context import TaskContext
|
||||
|
||||
|
||||
# #region DebugPlugin [TYPE Class]
|
||||
# @BRIEF Plugin for system diagnostics and debugging.
|
||||
class DebugPlugin(PluginBase):
|
||||
@@ -75,7 +77,7 @@ class DebugPlugin(PluginBase):
|
||||
# @PRE: Plugin instance exists.
|
||||
# @POST: Returns dictionary schema.
|
||||
# @RETURN: Dict[str, Any] - JSON schema.
|
||||
def get_schema(self) -> Dict[str, Any]:
|
||||
def get_schema(self) -> dict[str, Any]:
|
||||
with belief_scope("get_schema"):
|
||||
return {
|
||||
"type": "object",
|
||||
@@ -118,17 +120,17 @@ class DebugPlugin(PluginBase):
|
||||
# @PRE: action must be provided in params.
|
||||
# @POST: Debug action is executed and results returned.
|
||||
# @RETURN: Dict[str, Any] - Execution results.
|
||||
async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None) -> Dict[str, Any]:
|
||||
async def execute(self, params: dict[str, Any], context: TaskContext | None = None) -> dict[str, Any]:
|
||||
with belief_scope("execute"):
|
||||
action = params.get("action")
|
||||
|
||||
|
||||
# Use TaskContext logger if available, otherwise fall back to app logger
|
||||
log = context.logger if context else logger
|
||||
debug_log = log.with_source("debug") if context else log
|
||||
superset_log = log.with_source("superset_api") if context else log
|
||||
|
||||
|
||||
debug_log.info(f"Executing debug action: {action}")
|
||||
|
||||
|
||||
if action == "test-db-api":
|
||||
return await self._test_db_api(params, superset_log)
|
||||
elif action == "get-dataset-structure":
|
||||
@@ -145,17 +147,17 @@ class DebugPlugin(PluginBase):
|
||||
# @PARAM: params (Dict) - Plugin parameters.
|
||||
# @PARAM: log - Logger instance for superset_api source.
|
||||
# @RETURN: Dict - Comparison results.
|
||||
async def _test_db_api(self, params: Dict[str, Any], log) -> Dict[str, Any]:
|
||||
async def _test_db_api(self, params: dict[str, Any], log) -> dict[str, Any]:
|
||||
with belief_scope("_test_db_api"):
|
||||
source_env_name = params.get("source_env")
|
||||
target_env_name = params.get("target_env")
|
||||
|
||||
|
||||
if not source_env_name or not target_env_name:
|
||||
raise ValueError("source_env and target_env are required for test-db-api")
|
||||
|
||||
from ..dependencies import get_config_manager
|
||||
config_manager = get_config_manager()
|
||||
|
||||
|
||||
results = {}
|
||||
for name in [source_env_name, target_env_name]:
|
||||
log.info(f"Testing database API for environment: {name}")
|
||||
@@ -163,7 +165,7 @@ class DebugPlugin(PluginBase):
|
||||
if not env_config:
|
||||
log.error(f"Environment '{name}' not found.")
|
||||
raise ValueError(f"Environment '{name}' not found.")
|
||||
|
||||
|
||||
client = SupersetClient(env_config)
|
||||
client.authenticate()
|
||||
count, dbs = client.get_databases()
|
||||
@@ -172,7 +174,7 @@ class DebugPlugin(PluginBase):
|
||||
"count": count,
|
||||
"databases": dbs
|
||||
}
|
||||
|
||||
|
||||
return results
|
||||
# endregion _test_db_api
|
||||
|
||||
@@ -183,30 +185,30 @@ class DebugPlugin(PluginBase):
|
||||
# @PARAM: params (Dict) - Plugin parameters.
|
||||
# @PARAM: log - Logger instance for superset_api source.
|
||||
# @RETURN: Dict - Dataset structure.
|
||||
async def _get_dataset_structure(self, params: Dict[str, Any], log) -> Dict[str, Any]:
|
||||
async def _get_dataset_structure(self, params: dict[str, Any], log) -> dict[str, Any]:
|
||||
with belief_scope("_get_dataset_structure"):
|
||||
env_name = params.get("env")
|
||||
dataset_id = params.get("dataset_id")
|
||||
|
||||
|
||||
if not env_name or dataset_id is None:
|
||||
raise ValueError("env and dataset_id are required for get-dataset-structure")
|
||||
|
||||
log.info(f"Fetching structure for dataset {dataset_id} in {env_name}")
|
||||
|
||||
|
||||
from ..dependencies import get_config_manager
|
||||
config_manager = get_config_manager()
|
||||
env_config = config_manager.get_environment(env_name)
|
||||
if not env_config:
|
||||
log.error(f"Environment '{env_name}' not found.")
|
||||
raise ValueError(f"Environment '{env_name}' not found.")
|
||||
|
||||
|
||||
client = SupersetClient(env_config)
|
||||
client.authenticate()
|
||||
|
||||
|
||||
dataset_response = client.get_dataset(dataset_id)
|
||||
log.debug(f"Retrieved dataset structure for {dataset_id}")
|
||||
return dataset_response.get('result') or {}
|
||||
# endregion _get_dataset_structure
|
||||
|
||||
# #endregion DebugPlugin
|
||||
# #endregion DebugPluginModule
|
||||
# #endregion DebugPluginModule
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [LLMClient]
|
||||
|
||||
from typing import List
|
||||
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
from ..llm_analysis.service import LLMClient
|
||||
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...services.llm_prompt_templates import DEFAULT_LLM_PROMPTS, render_prompt
|
||||
from ..llm_analysis.service import LLMClient
|
||||
|
||||
|
||||
# #region GitLLMExtension [TYPE Class]
|
||||
# @BRIEF Provides LLM capabilities to the Git plugin.
|
||||
@@ -28,7 +30,7 @@ class GitLLMExtension:
|
||||
async def suggest_commit_message(
|
||||
self,
|
||||
diff: str,
|
||||
history: List[str],
|
||||
history: list[str],
|
||||
prompt_template: str = DEFAULT_LLM_PROMPTS["git_commit_prompt"],
|
||||
) -> str:
|
||||
with belief_scope("suggest_commit_message"):
|
||||
@@ -37,20 +39,20 @@ class GitLLMExtension:
|
||||
prompt_template,
|
||||
{"history": history_text, "diff": diff},
|
||||
)
|
||||
|
||||
|
||||
logger.debug(f"[suggest_commit_message] Calling LLM with model: {self.client.default_model}")
|
||||
response = await self.client.client.chat.completions.create(
|
||||
model=self.client.default_model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.7
|
||||
)
|
||||
|
||||
|
||||
logger.debug(f"[suggest_commit_message] LLM Response: {response}")
|
||||
|
||||
|
||||
if not response or not hasattr(response, 'choices') or not response.choices:
|
||||
error_info = getattr(response, 'error', 'No choices in response')
|
||||
logger.error(f"[suggest_commit_message] Invalid LLM response. Error info: {error_info}")
|
||||
|
||||
|
||||
# If it's a timeout/provider error, we might want to throw to trigger retry if decorated
|
||||
# but for now we return a safe fallback to avoid UI crash
|
||||
return "Update dashboard configurations (LLM generation failed)"
|
||||
|
||||
@@ -11,25 +11,26 @@
|
||||
# @INVARIANT: Все операции с Git должны выполняться через GitService.
|
||||
# @CONSTRAINT: Плагин работает только с распакованными YAML-экспортами Superset.
|
||||
|
||||
import os
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
from src.core.plugin_base import PluginBase
|
||||
from src.services.git_service import GitService
|
||||
from src.core.logger import logger as app_logger, belief_scope
|
||||
from typing import Any
|
||||
|
||||
from src.core.config_manager import ConfigManager
|
||||
from src.core.logger import belief_scope
|
||||
from src.core.logger import logger as app_logger
|
||||
from src.core.plugin_base import PluginBase
|
||||
from src.core.superset_client import SupersetClient
|
||||
from src.core.task_manager.context import TaskContext
|
||||
from src.core.database import SessionLocal
|
||||
from src.core.mapping_service import IdMappingService
|
||||
from src.services.git_service import GitService
|
||||
|
||||
|
||||
# #region GitPlugin [TYPE Class]
|
||||
# @BRIEF Реализация плагина Git Integration для управления версиями дашбордов.
|
||||
class GitPlugin(PluginBase):
|
||||
|
||||
|
||||
# region __init__ [TYPE Function]
|
||||
# @PURPOSE: Инициализирует плагин и его зависимости.
|
||||
# @PRE: config.json exists or shared config_manager is available.
|
||||
@@ -38,7 +39,7 @@ class GitPlugin(PluginBase):
|
||||
with belief_scope("GitPlugin.__init__"):
|
||||
app_logger.info("Initializing GitPlugin.")
|
||||
self.git_service = GitService()
|
||||
|
||||
|
||||
# Robust config path resolution:
|
||||
# 1. Try absolute path from src/dependencies.py style if possible
|
||||
# 2. Try relative paths based on common execution patterns
|
||||
@@ -114,7 +115,7 @@ class GitPlugin(PluginBase):
|
||||
# @PRE: GitPlugin is initialized.
|
||||
# @POST: Returns a JSON schema dictionary.
|
||||
# @RETURN: Dict[str, Any] - Схема параметров.
|
||||
def get_schema(self) -> Dict[str, Any]:
|
||||
def get_schema(self) -> dict[str, Any]:
|
||||
with belief_scope("GitPlugin.get_schema"):
|
||||
return {
|
||||
"type": "object",
|
||||
@@ -145,20 +146,20 @@ class GitPlugin(PluginBase):
|
||||
# @RETURN: Dict[str, Any] - Статус и сообщение.
|
||||
# @RELATION: CALLS -> self._handle_sync
|
||||
# @RELATION: CALLS -> self._handle_deploy
|
||||
async def execute(self, task_data: Dict[str, Any], context: Optional[TaskContext] = None) -> Dict[str, Any]:
|
||||
async def execute(self, task_data: dict[str, Any], context: TaskContext | None = None) -> dict[str, Any]:
|
||||
with belief_scope("GitPlugin.execute"):
|
||||
operation = task_data.get("operation")
|
||||
dashboard_id = task_data.get("dashboard_id")
|
||||
|
||||
|
||||
# Use TaskContext logger if available, otherwise fall back to app_logger
|
||||
log = context.logger if context else app_logger
|
||||
|
||||
|
||||
# Create sub-loggers for different components
|
||||
git_log = log.with_source("git") if context else log
|
||||
superset_log = log.with_source("superset_api") if context else log
|
||||
|
||||
|
||||
log.info(f"Executing operation: {operation} for dashboard {dashboard_id}")
|
||||
|
||||
|
||||
if operation == "sync":
|
||||
source_env_id = task_data.get("source_env_id")
|
||||
result = await self._handle_sync(dashboard_id, source_env_id, log, git_log, superset_log)
|
||||
@@ -170,7 +171,7 @@ class GitPlugin(PluginBase):
|
||||
else:
|
||||
log.error(f"Unknown operation: {operation}")
|
||||
raise ValueError(f"Unknown operation: {operation}")
|
||||
|
||||
|
||||
log.info(f"Operation {operation} completed.")
|
||||
return result
|
||||
# endregion execute
|
||||
@@ -185,7 +186,7 @@ class GitPlugin(PluginBase):
|
||||
# @SIDE_EFFECT: Изменяет файлы в локальной рабочей директории репозитория.
|
||||
# @RELATION: CALLS -> src.services.git_service.GitService.get_repo
|
||||
# @RELATION: CALLS -> src.core.superset_client.SupersetClient.export_dashboard
|
||||
async def _handle_sync(self, dashboard_id: int, source_env_id: Optional[str] = None, log=None, git_log=None, superset_log=None) -> Dict[str, str]:
|
||||
async def _handle_sync(self, dashboard_id: int, source_env_id: str | None = None, log=None, git_log=None, superset_log=None) -> dict[str, str]:
|
||||
with belief_scope("GitPlugin._handle_sync"):
|
||||
try:
|
||||
# 1. Получение репозитория
|
||||
@@ -201,14 +202,14 @@ class GitPlugin(PluginBase):
|
||||
# 3. Экспорт дашборда
|
||||
superset_log.info(f"Exporting dashboard {dashboard_id} from {env.name}")
|
||||
zip_bytes, _ = client.export_dashboard(dashboard_id)
|
||||
|
||||
|
||||
# 4. Распаковка с выравниванием структуры (flattening)
|
||||
git_log.info(f"Unpacking export to {repo_path}")
|
||||
|
||||
|
||||
# Список папок/файлов, которые мы ожидаем от Superset
|
||||
managed_dirs = ["dashboards", "charts", "datasets", "databases"]
|
||||
managed_files = ["metadata.yaml"]
|
||||
|
||||
|
||||
# Очистка старых данных перед распаковкой, чтобы не оставалось "призраков"
|
||||
for d in managed_dirs:
|
||||
d_path = repo_path / d
|
||||
@@ -225,23 +226,23 @@ class GitPlugin(PluginBase):
|
||||
namelist = zf.namelist()
|
||||
if not namelist:
|
||||
raise ValueError("Export ZIP is empty")
|
||||
|
||||
|
||||
root_folder = namelist[0].split('/')[0]
|
||||
git_log.info(f"Detected root folder in ZIP: {root_folder}")
|
||||
|
||||
|
||||
for member in zf.infolist():
|
||||
if member.filename.startswith(root_folder + "/") and len(member.filename) > len(root_folder) + 1:
|
||||
# Убираем префикс папки
|
||||
relative_path = member.filename[len(root_folder)+1:]
|
||||
target_path = repo_path / relative_path
|
||||
|
||||
|
||||
if member.is_dir():
|
||||
target_path.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zf.open(member) as source, open(target_path, "wb") as target:
|
||||
shutil.copyfileobj(source, target)
|
||||
|
||||
|
||||
# 5. Автоматический staging изменений (не коммит, чтобы юзер мог проверить diff)
|
||||
try:
|
||||
repo.git.add(A=True)
|
||||
@@ -269,7 +270,7 @@ class GitPlugin(PluginBase):
|
||||
# @RETURN: Dict[str, Any] - Результат деплоя.
|
||||
# @SIDE_EFFECT: Создает и удаляет временный ZIP-файл.
|
||||
# @RELATION: CALLS -> src.core.superset_client.SupersetClient.import_dashboard
|
||||
async def _handle_deploy(self, dashboard_id: int, env_id: str, log=None, git_log=None, superset_log=None) -> Dict[str, Any]:
|
||||
async def _handle_deploy(self, dashboard_id: int, env_id: str, log=None, git_log=None, superset_log=None) -> dict[str, Any]:
|
||||
with belief_scope("GitPlugin._handle_deploy"):
|
||||
try:
|
||||
if not env_id:
|
||||
@@ -282,10 +283,10 @@ class GitPlugin(PluginBase):
|
||||
# 2. Упаковка в ZIP
|
||||
git_log.info(f"Packing repository {repo_path} for deployment.")
|
||||
zip_buffer = io.BytesIO()
|
||||
|
||||
|
||||
# Superset expects a root directory in the ZIP (e.g., dashboard_export_20240101T000000/)
|
||||
root_dir_name = f"dashboard_export_{dashboard_id}"
|
||||
|
||||
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, dirs, files in os.walk(repo_path):
|
||||
if ".git" in dirs:
|
||||
@@ -297,14 +298,14 @@ class GitPlugin(PluginBase):
|
||||
# Prepend the root directory name to the archive path
|
||||
arcname = Path(root_dir_name) / file_path.relative_to(repo_path)
|
||||
zf.write(file_path, arcname)
|
||||
|
||||
|
||||
zip_buffer.seek(0)
|
||||
|
||||
|
||||
# 3. Настройка клиента Superset
|
||||
env = self.config_manager.get_environment(env_id)
|
||||
if not env:
|
||||
raise ValueError(f"Environment {env_id} not found")
|
||||
|
||||
|
||||
client = SupersetClient(env)
|
||||
client.authenticate()
|
||||
|
||||
@@ -313,7 +314,7 @@ class GitPlugin(PluginBase):
|
||||
git_log.info(f"Saving temporary zip to {temp_zip_path}")
|
||||
with open(temp_zip_path, "wb") as f:
|
||||
f.write(zip_buffer.getvalue())
|
||||
|
||||
|
||||
try:
|
||||
app_logger.reason(f"Importing dashboard to {env.name}", extra={"src": "_handle_deploy"})
|
||||
result = client.import_dashboard(temp_zip_path)
|
||||
@@ -334,21 +335,21 @@ class GitPlugin(PluginBase):
|
||||
# @PRE: env_id is a string or None.
|
||||
# @POST: Returns an Environment object from config or DB.
|
||||
# @RETURN: Environment - Объект конфигурации окружения.
|
||||
def _get_env(self, env_id: Optional[str] = None):
|
||||
def _get_env(self, env_id: str | None = None):
|
||||
with belief_scope("GitPlugin._get_env"):
|
||||
app_logger.reason(f"Fetching environment for ID: {env_id}", extra={"src": "_get_env"})
|
||||
|
||||
|
||||
# Priority 1: ConfigManager (config.json)
|
||||
if env_id:
|
||||
env = self.config_manager.get_environment(env_id)
|
||||
if env:
|
||||
app_logger.reflect(f"Found environment by ID in ConfigManager: {env.name}", extra={"src": "_get_env"})
|
||||
return env
|
||||
|
||||
|
||||
# Priority 2: Database (DeploymentEnvironment)
|
||||
from src.core.database import SessionLocal
|
||||
from src.models.git import DeploymentEnvironment
|
||||
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if env_id:
|
||||
@@ -373,7 +374,7 @@ class GitPlugin(PluginBase):
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# Priority 3: ConfigManager Default (if no env_id provided)
|
||||
envs = self.config_manager.get_environments()
|
||||
if envs:
|
||||
@@ -384,15 +385,15 @@ class GitPlugin(PluginBase):
|
||||
if env:
|
||||
app_logger.reflect(f"Found environment {env_id} in ConfigManager list", extra={"src": "_get_env"})
|
||||
return env
|
||||
|
||||
|
||||
if not env_id:
|
||||
app_logger.reflect(f"Using first environment from ConfigManager: {envs[0].name}", extra={"src": "_get_env"})
|
||||
return envs[0]
|
||||
|
||||
|
||||
app_logger.error(f"[_get_env][Coherence:Failed] No environments configured (searched config.json and DB). env_id={env_id}")
|
||||
raise ValueError("No environments configured. Please add a Superset Environment in Settings.")
|
||||
# endregion _get_env
|
||||
|
||||
# endregion initialize
|
||||
# #endregion GitPlugin
|
||||
# #endregion GitPluginModule
|
||||
# #endregion GitPluginModule
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
# @RELATION DEPENDS_on -> pydantic
|
||||
# @RELATION DEPENDs_on -> pydantic
|
||||
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# #region LLMProviderType [TYPE Class]
|
||||
# @BRIEF Enum for supported LLM providers.
|
||||
class LLMProviderType(str, Enum):
|
||||
@@ -21,11 +22,11 @@ class LLMProviderType(str, Enum):
|
||||
# #region LLMProviderConfig [TYPE Class]
|
||||
# @BRIEF Configuration for an LLM provider.
|
||||
class LLMProviderConfig(BaseModel):
|
||||
id: Optional[str] = None
|
||||
id: str | None = None
|
||||
provider_type: LLMProviderType
|
||||
name: str
|
||||
base_url: str
|
||||
api_key: Optional[str] = None
|
||||
api_key: str | None = None
|
||||
default_model: str
|
||||
is_active: bool = True
|
||||
# #endregion LLMProviderConfig
|
||||
@@ -44,20 +45,20 @@ class ValidationStatus(str, Enum):
|
||||
class DetectedIssue(BaseModel):
|
||||
severity: ValidationStatus
|
||||
message: str
|
||||
location: Optional[str] = None
|
||||
location: str | None = None
|
||||
# #endregion DetectedIssue
|
||||
|
||||
# #region ValidationResult [TYPE Class]
|
||||
# @BRIEF Model for dashboard validation result.
|
||||
class ValidationResult(BaseModel):
|
||||
id: Optional[str] = None
|
||||
id: str | None = None
|
||||
dashboard_id: str
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||
status: ValidationStatus
|
||||
screenshot_path: Optional[str] = None
|
||||
issues: List[DetectedIssue]
|
||||
screenshot_path: str | None = None
|
||||
issues: list[DetectedIssue]
|
||||
summary: str
|
||||
raw_response: Optional[str] = None
|
||||
raw_response: str | None = None
|
||||
# #endregion ValidationResult
|
||||
|
||||
# #endregion LLMAnalysisModels
|
||||
|
||||
@@ -8,32 +8,34 @@
|
||||
# @RELATION USES -> TaskContext
|
||||
# @INVARIANT: All LLM interactions must be executed as asynchronous tasks.
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
import os
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from ...core.plugin_base import PluginBase
|
||||
from ...core.logger import belief_scope, logger
|
||||
from typing import Any
|
||||
|
||||
from ...core.database import SessionLocal
|
||||
from ...services.llm_provider import LLMProviderService
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...core.plugin_base import PluginBase
|
||||
from ...core.superset_client import SupersetClient
|
||||
from .service import ScreenshotService, LLMClient
|
||||
from .models import LLMProviderType, ValidationStatus, ValidationResult, DetectedIssue
|
||||
from ...models.llm import ValidationRecord, ValidationPolicy
|
||||
from ...core.task_manager.context import TaskContext
|
||||
from ...services.notifications.service import NotificationService
|
||||
from ...models.llm import ValidationPolicy, ValidationRecord
|
||||
from ...services.llm_prompt_templates import (
|
||||
DEFAULT_LLM_PROMPTS,
|
||||
is_multimodal_model,
|
||||
normalize_llm_settings,
|
||||
render_prompt,
|
||||
)
|
||||
from ...services.llm_provider import LLMProviderService
|
||||
from ...services.notifications.service import NotificationService
|
||||
from .models import DetectedIssue, LLMProviderType, ValidationResult, ValidationStatus
|
||||
from .service import LLMClient, ScreenshotService
|
||||
|
||||
|
||||
# #region _is_masked_or_invalid_api_key [TYPE Function]
|
||||
# @BRIEF Guards against placeholder or malformed API keys in runtime.
|
||||
# @PRE: value may be None.
|
||||
# @POST: Returns True when value cannot be used for authenticated provider calls.
|
||||
def _is_masked_or_invalid_api_key(value: Optional[str]) -> bool:
|
||||
def _is_masked_or_invalid_api_key(value: str | None) -> bool:
|
||||
key = (value or "").strip()
|
||||
if not key:
|
||||
return True
|
||||
@@ -77,7 +79,7 @@ class DashboardValidationPlugin(PluginBase):
|
||||
def version(self) -> str:
|
||||
return "1.0.0"
|
||||
|
||||
def get_schema(self) -> Dict[str, Any]:
|
||||
def get_schema(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -95,19 +97,19 @@ class DashboardValidationPlugin(PluginBase):
|
||||
# @PRE: params contains dashboard_id, environment_id, and provider_id.
|
||||
# @POST: Returns a dictionary with validation results and persists them to the database.
|
||||
# @SIDE_EFFECT: Captures a screenshot, calls LLM API, and writes to the database.
|
||||
async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None):
|
||||
async def execute(self, params: dict[str, Any], context: TaskContext | None = None):
|
||||
with belief_scope("execute", f"plugin_id={self.id}"):
|
||||
validation_started_at = datetime.utcnow()
|
||||
# Use TaskContext logger if available, otherwise fall back to app logger
|
||||
log = context.logger if context else logger
|
||||
|
||||
|
||||
# Create sub-loggers for different components
|
||||
llm_log = log.with_source("llm") if context else log
|
||||
screenshot_log = log.with_source("screenshot") if context else log
|
||||
superset_log = log.with_source("superset_api") if context else log
|
||||
|
||||
|
||||
log.info(f"Executing {self.name} with params: {params}")
|
||||
|
||||
|
||||
dashboard_id_raw = params.get("dashboard_id")
|
||||
dashboard_id = str(dashboard_id_raw) if dashboard_id_raw is not None else None
|
||||
env_id = params.get("environment_id")
|
||||
@@ -129,7 +131,7 @@ class DashboardValidationPlugin(PluginBase):
|
||||
if not db_provider:
|
||||
log.error(f"LLM Provider {provider_id} not found")
|
||||
raise ValueError(f"LLM Provider {provider_id} not found")
|
||||
|
||||
|
||||
llm_log.debug("Retrieved provider config:")
|
||||
llm_log.debug(f" Provider ID: {db_provider.id}")
|
||||
llm_log.debug(f" Provider Name: {db_provider.name}")
|
||||
@@ -141,10 +143,10 @@ class DashboardValidationPlugin(PluginBase):
|
||||
raise ValueError(
|
||||
"Dashboard validation requires a multimodal model (image input support)."
|
||||
)
|
||||
|
||||
|
||||
api_key = llm_service.get_decrypted_api_key(provider_id)
|
||||
llm_log.debug(f"API Key decrypted (first 8 chars): {api_key[:8] if api_key and len(api_key) > 8 else 'EMPTY_OR_NONE'}...")
|
||||
|
||||
|
||||
# Check if API key was successfully decrypted
|
||||
if _is_masked_or_invalid_api_key(api_key):
|
||||
raise ValueError(
|
||||
@@ -154,14 +156,14 @@ class DashboardValidationPlugin(PluginBase):
|
||||
|
||||
# 3. Capture Screenshot
|
||||
screenshot_service = ScreenshotService(env)
|
||||
|
||||
|
||||
storage_root = config_mgr.get_config().settings.storage.root_path
|
||||
screenshots_dir = os.path.join(storage_root, "screenshots")
|
||||
os.makedirs(screenshots_dir, exist_ok=True)
|
||||
|
||||
|
||||
filename = f"{dashboard_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
|
||||
screenshot_path = os.path.join(screenshots_dir, filename)
|
||||
|
||||
|
||||
screenshot_started_at = datetime.utcnow()
|
||||
screenshot_log.info(f"Capturing screenshot for dashboard {dashboard_id}")
|
||||
await screenshot_service.capture_dashboard(dashboard_id, screenshot_path)
|
||||
@@ -173,10 +175,10 @@ class DashboardValidationPlugin(PluginBase):
|
||||
logs_fetch_started_at = datetime.utcnow()
|
||||
try:
|
||||
client = SupersetClient(env)
|
||||
|
||||
|
||||
# Calculate time window (last 24 hours)
|
||||
start_time = (datetime.now() - timedelta(hours=24)).isoformat()
|
||||
|
||||
|
||||
# Construct filter for logs
|
||||
# Note: We filter by dashboard_id matching the object
|
||||
query_params = {
|
||||
@@ -189,28 +191,28 @@ class DashboardValidationPlugin(PluginBase):
|
||||
"page": 0,
|
||||
"page_size": 100
|
||||
}
|
||||
|
||||
|
||||
superset_log.debug(f"Fetching logs for dashboard {dashboard_id}")
|
||||
response = client.network.request(
|
||||
method="GET",
|
||||
endpoint="/log/",
|
||||
params={"q": json.dumps(query_params)}
|
||||
)
|
||||
|
||||
|
||||
if isinstance(response, dict) and "result" in response:
|
||||
for item in response["result"]:
|
||||
action = item.get("action", "unknown")
|
||||
dttm = item.get("dttm", "")
|
||||
details = item.get("json", "")
|
||||
logs.append(f"[{dttm}] {action}: {details}")
|
||||
|
||||
|
||||
if not logs:
|
||||
logs = ["No recent logs found for this dashboard."]
|
||||
superset_log.debug("No recent logs found for this dashboard")
|
||||
|
||||
except Exception as e:
|
||||
superset_log.warning(f"Failed to fetch logs from environment: {e}")
|
||||
logs = [f"Error fetching remote logs: {str(e)}"]
|
||||
logs = [f"Error fetching remote logs: {e!s}"]
|
||||
logs_fetch_finished_at = datetime.utcnow()
|
||||
|
||||
# 5. Analyze with LLM
|
||||
@@ -220,7 +222,7 @@ class DashboardValidationPlugin(PluginBase):
|
||||
base_url=db_provider.base_url,
|
||||
default_model=db_provider.default_model
|
||||
)
|
||||
|
||||
|
||||
llm_log.info(f"Analyzing dashboard {dashboard_id} with LLM")
|
||||
llm_settings = normalize_llm_settings(config_mgr.get_config().settings.llm)
|
||||
dashboard_prompt = llm_settings["prompts"].get(
|
||||
@@ -234,7 +236,7 @@ class DashboardValidationPlugin(PluginBase):
|
||||
prompt_template=dashboard_prompt,
|
||||
)
|
||||
llm_call_finished_at = datetime.utcnow()
|
||||
|
||||
|
||||
# Log analysis summary to task logs for better visibility
|
||||
llm_log.info(f"[ANALYSIS_SUMMARY] Status: {analysis['status']}")
|
||||
llm_log.info(f"[ANALYSIS_SUMMARY] Summary: {analysis['summary']}")
|
||||
@@ -300,7 +302,7 @@ class DashboardValidationPlugin(PluginBase):
|
||||
policy = None
|
||||
if policy_id:
|
||||
policy = db.query(ValidationPolicy).filter(ValidationPolicy.id == policy_id).first()
|
||||
|
||||
|
||||
notification_service = NotificationService(db, config_mgr)
|
||||
await notification_service.dispatch_report(
|
||||
record=db_record,
|
||||
@@ -312,7 +314,7 @@ class DashboardValidationPlugin(PluginBase):
|
||||
|
||||
# Final log to ensure all analysis is visible in task logs
|
||||
log.info(f"Validation completed for dashboard {dashboard_id}. Status: {validation_result.status.value}")
|
||||
|
||||
|
||||
return result_payload
|
||||
|
||||
finally:
|
||||
@@ -340,7 +342,7 @@ class DocumentationPlugin(PluginBase):
|
||||
def version(self) -> str:
|
||||
return "1.0.0"
|
||||
|
||||
def get_schema(self) -> Dict[str, Any]:
|
||||
def get_schema(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -358,17 +360,17 @@ class DocumentationPlugin(PluginBase):
|
||||
# @PRE: params contains dataset_id, environment_id, and provider_id.
|
||||
# @POST: Returns generated documentation and updates the dataset in Superset.
|
||||
# @SIDE_EFFECT: Calls LLM API and updates dataset metadata in Superset.
|
||||
async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None):
|
||||
async def execute(self, params: dict[str, Any], context: TaskContext | None = None):
|
||||
with belief_scope("execute", f"plugin_id={self.id}"):
|
||||
# Use TaskContext logger if available, otherwise fall back to app logger
|
||||
log = context.logger if context else logger
|
||||
|
||||
|
||||
# Create sub-loggers for different components
|
||||
llm_log = log.with_source("llm") if context else log
|
||||
superset_log = log.with_source("superset_api") if context else log
|
||||
|
||||
|
||||
log.info(f"Executing {self.name} with params: {params}")
|
||||
|
||||
|
||||
dataset_id = params.get("dataset_id")
|
||||
env_id = params.get("environment_id")
|
||||
provider_id = params.get("provider_id")
|
||||
@@ -389,17 +391,17 @@ class DocumentationPlugin(PluginBase):
|
||||
if not db_provider:
|
||||
log.error(f"LLM Provider {provider_id} not found")
|
||||
raise ValueError(f"LLM Provider {provider_id} not found")
|
||||
|
||||
|
||||
llm_log.debug("Retrieved provider config:")
|
||||
llm_log.debug(f" Provider ID: {db_provider.id}")
|
||||
llm_log.debug(f" Provider Name: {db_provider.name}")
|
||||
llm_log.debug(f" Provider Type: {db_provider.provider_type}")
|
||||
llm_log.debug(f" Base URL: {db_provider.base_url}")
|
||||
llm_log.debug(f" Default Model: {db_provider.default_model}")
|
||||
|
||||
|
||||
api_key = llm_service.get_decrypted_api_key(provider_id)
|
||||
llm_log.debug(f"API Key decrypted (first 8 chars): {api_key[:8] if api_key and len(api_key) > 8 else 'EMPTY_OR_NONE'}...")
|
||||
|
||||
|
||||
# Check if API key was successfully decrypted
|
||||
if _is_masked_or_invalid_api_key(api_key):
|
||||
raise ValueError(
|
||||
@@ -410,10 +412,10 @@ class DocumentationPlugin(PluginBase):
|
||||
# 3. Fetch Metadata (US2 / T024)
|
||||
from ...core.superset_client import SupersetClient
|
||||
client = SupersetClient(env)
|
||||
|
||||
|
||||
superset_log.debug(f"Fetching dataset {dataset_id}")
|
||||
dataset = client.get_dataset(int(dataset_id))
|
||||
|
||||
|
||||
# Extract columns and existing descriptions
|
||||
columns_data = []
|
||||
for col in dataset.get("columns", []):
|
||||
@@ -431,7 +433,7 @@ class DocumentationPlugin(PluginBase):
|
||||
base_url=db_provider.base_url,
|
||||
default_model=db_provider.default_model
|
||||
)
|
||||
|
||||
|
||||
llm_settings = normalize_llm_settings(config_mgr.get_config().settings.llm)
|
||||
documentation_prompt = llm_settings["prompts"].get(
|
||||
"documentation_prompt",
|
||||
@@ -444,7 +446,7 @@ class DocumentationPlugin(PluginBase):
|
||||
"columns_json": json.dumps(columns_data, ensure_ascii=False),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Using a generic chat completion for text-only US2
|
||||
llm_log.info(f"Generating documentation for dataset {dataset_id}")
|
||||
doc_result = await llm_client.get_json_completion([{"role": "user", "content": prompt}])
|
||||
@@ -454,7 +456,7 @@ class DocumentationPlugin(PluginBase):
|
||||
"description": doc_result["dataset_description"],
|
||||
"columns": []
|
||||
}
|
||||
|
||||
|
||||
# Map generated descriptions back to column IDs
|
||||
for col_doc in doc_result["column_descriptions"]:
|
||||
for col in dataset.get("columns", []):
|
||||
@@ -466,9 +468,9 @@ class DocumentationPlugin(PluginBase):
|
||||
|
||||
superset_log.info(f"Updating dataset {dataset_id} with generated documentation")
|
||||
client.update_dataset(int(dataset_id), update_payload)
|
||||
|
||||
|
||||
log.info(f"Documentation completed for dataset {dataset_id}")
|
||||
|
||||
|
||||
return doc_result
|
||||
|
||||
finally:
|
||||
|
||||
@@ -3,20 +3,22 @@
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [SchedulerService]
|
||||
|
||||
from typing import Dict, Any
|
||||
from ...dependencies import get_task_manager, get_scheduler_service
|
||||
from typing import Any
|
||||
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...dependencies import get_scheduler_service, get_task_manager
|
||||
|
||||
|
||||
# #region schedule_dashboard_validation [TYPE Function]
|
||||
# @BRIEF Schedules a recurring dashboard validation task.
|
||||
# @SIDE_EFFECT: Adds a job to the scheduler service.
|
||||
def schedule_dashboard_validation(dashboard_id: str, cron_expression: str, params: Dict[str, Any]):
|
||||
def schedule_dashboard_validation(dashboard_id: str, cron_expression: str, params: dict[str, Any]):
|
||||
with belief_scope("schedule_dashboard_validation", f"dashboard_id={dashboard_id}"):
|
||||
scheduler = get_scheduler_service()
|
||||
task_manager = get_task_manager()
|
||||
|
||||
|
||||
job_id = f"llm_val_{dashboard_id}"
|
||||
|
||||
|
||||
async def job_func():
|
||||
await task_manager.create_task(
|
||||
plugin_id="llm_dashboard_validation",
|
||||
@@ -38,7 +40,7 @@ def schedule_dashboard_validation(dashboard_id: str, cron_expression: str, param
|
||||
|
||||
# #region _parse_cron [TYPE Function]
|
||||
# @BRIEF Basic cron parser placeholder.
|
||||
def _parse_cron(cron: str) -> Dict[str, str]:
|
||||
def _parse_cron(cron: str) -> dict[str, str]:
|
||||
# Basic cron parser placeholder
|
||||
parts = cron.split()
|
||||
if len(parts) != 5:
|
||||
|
||||
@@ -8,20 +8,24 @@
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
from typing import List, Dict, Any
|
||||
|
||||
import httpx
|
||||
from openai import AsyncOpenAI, RateLimitError
|
||||
from openai import AuthenticationError as OpenAIAuthenticationError
|
||||
from PIL import Image
|
||||
from playwright.async_api import async_playwright
|
||||
from openai import AsyncOpenAI, RateLimitError, AuthenticationError as OpenAIAuthenticationError
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception
|
||||
from .models import LLMProviderType
|
||||
from ...core.logger import belief_scope, logger
|
||||
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
|
||||
|
||||
from ...core.config_models import Environment
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...services.llm_prompt_templates import DEFAULT_LLM_PROMPTS, render_prompt
|
||||
from .models import LLMProviderType
|
||||
|
||||
|
||||
# #region ScreenshotService [TYPE Class]
|
||||
# @BRIEF Handles capturing screenshots of Superset dashboards.
|
||||
@@ -54,7 +58,7 @@ class ScreenshotService:
|
||||
# @PURPOSE: Enumerate page and child frames where login controls may be rendered.
|
||||
# @PRE: page is a Playwright page-like object.
|
||||
# @POST: Returns ordered roots starting with main page followed by frames.
|
||||
def _iter_login_roots(self, page) -> List[Any]:
|
||||
def _iter_login_roots(self, page) -> list[Any]:
|
||||
roots = [page]
|
||||
page_frames = getattr(page, "frames", [])
|
||||
try:
|
||||
@@ -70,8 +74,8 @@ class ScreenshotService:
|
||||
# @PURPOSE: Collect hidden form fields required for direct login POST fallback.
|
||||
# @PRE: Login page is loaded.
|
||||
# @POST: Returns hidden input name/value mapping aggregated from page and child frames.
|
||||
async def _extract_hidden_login_fields(self, page) -> Dict[str, str]:
|
||||
hidden_fields: Dict[str, str] = {}
|
||||
async def _extract_hidden_login_fields(self, page) -> dict[str, str]:
|
||||
hidden_fields: dict[str, str] = {}
|
||||
for root in self._iter_login_roots(page):
|
||||
try:
|
||||
locator = root.locator("input[type='hidden'][name]")
|
||||
@@ -296,7 +300,7 @@ class ScreenshotService:
|
||||
base_ui_url = self.env.url.rstrip("/")
|
||||
if base_ui_url.endswith("/api/v1"):
|
||||
base_ui_url = base_ui_url[:-len("/api/v1")]
|
||||
|
||||
|
||||
# Create browser context with realistic headers
|
||||
context = await browser.new_context(
|
||||
viewport={'width': 1280, 'height': 720},
|
||||
@@ -320,7 +324,7 @@ class ScreenshotService:
|
||||
# 1. Navigate to login page and authenticate
|
||||
login_url = f"{base_ui_url.rstrip('/')}/login/"
|
||||
logger.info(f"[DEBUG] Navigating to login page: {login_url}")
|
||||
|
||||
|
||||
response = await self._goto_resilient(
|
||||
page,
|
||||
login_url,
|
||||
@@ -330,10 +334,10 @@ class ScreenshotService:
|
||||
)
|
||||
if response:
|
||||
logger.info(f"[DEBUG] Login page response status: {response.status}")
|
||||
|
||||
|
||||
# Wait for login form to be ready
|
||||
await page.wait_for_load_state("domcontentloaded")
|
||||
|
||||
|
||||
# More exhaustive list of selectors for various Superset versions/themes
|
||||
selectors = {
|
||||
"username": ['input[name="username"]', 'input#username', 'input[placeholder*="Username"]', 'input[type="text"]'],
|
||||
@@ -341,7 +345,7 @@ class ScreenshotService:
|
||||
"submit": ['button[type="submit"]', 'button#submit', '.btn-primary', 'input[type="submit"]']
|
||||
}
|
||||
logger.info("[DEBUG] Attempting to find login form elements...")
|
||||
|
||||
|
||||
try:
|
||||
used_direct_form_login = False
|
||||
# Find and fill username
|
||||
@@ -362,21 +366,21 @@ class ScreenshotService:
|
||||
if not used_direct_form_login:
|
||||
raise RuntimeError("Could not find username input field on login page")
|
||||
username_locator = None
|
||||
|
||||
|
||||
if username_locator is not None:
|
||||
logger.info("[DEBUG] Filling username field")
|
||||
await username_locator.fill(self.env.username)
|
||||
|
||||
|
||||
# Find and fill password
|
||||
password_locator = await self._find_login_field_locator(page, "password") if username_locator is not None else None
|
||||
|
||||
if username_locator is not None and not password_locator:
|
||||
raise RuntimeError("Could not find password input field on login page")
|
||||
|
||||
|
||||
if password_locator is not None:
|
||||
logger.info("[DEBUG] Filling password field")
|
||||
await password_locator.fill(self.env.password)
|
||||
|
||||
|
||||
# Click submit
|
||||
submit_locator = await self._find_submit_locator(page) if username_locator is not None else None
|
||||
|
||||
@@ -386,14 +390,14 @@ class ScreenshotService:
|
||||
if submit_locator is not None:
|
||||
logger.info("[DEBUG] Clicking submit button")
|
||||
await submit_locator.click()
|
||||
|
||||
|
||||
# Wait for navigation after login
|
||||
if not used_direct_form_login:
|
||||
try:
|
||||
await page.wait_for_load_state("load", timeout=30000)
|
||||
except Exception as load_wait_error:
|
||||
logger.warning(f"[DEBUG] Login post-submit load wait timed out: {load_wait_error}")
|
||||
|
||||
|
||||
# Check if login was successful
|
||||
if not used_direct_form_login and "/login" in page.url:
|
||||
# Check for error messages on page
|
||||
@@ -402,31 +406,31 @@ class ScreenshotService:
|
||||
debug_path = output_path.replace(".png", "_debug_failed_login.png")
|
||||
await page.screenshot(path=debug_path)
|
||||
raise RuntimeError(f"Login failed: {error_msg}. Debug screenshot saved to {debug_path}")
|
||||
|
||||
|
||||
logger.info(f"[DEBUG] Login successful. Current URL: {page.url}")
|
||||
|
||||
|
||||
# Check cookies after successful login
|
||||
page_cookies = await context.cookies()
|
||||
logger.info(f"[DEBUG] Cookies after login: {len(page_cookies)}")
|
||||
for c in page_cookies:
|
||||
logger.info(f"[DEBUG] Cookie: name={c['name']}, domain={c['domain']}, value={c.get('value', '')[:20]}...")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
page_title = await page.title()
|
||||
logger.error(f"UI Login failed. Page title: {page_title}, URL: {page.url}, Error: {str(e)}")
|
||||
logger.error(f"UI Login failed. Page title: {page_title}, URL: {page.url}, Error: {e!s}")
|
||||
debug_path = output_path.replace(".png", "_debug_failed_login.png")
|
||||
await page.screenshot(path=debug_path)
|
||||
raise RuntimeError(f"Login failed: {str(e)}. Debug screenshot saved to {debug_path}")
|
||||
raise RuntimeError(f"Login failed: {e!s}. Debug screenshot saved to {debug_path}")
|
||||
|
||||
# 2. Navigate to dashboard
|
||||
# @UX_STATE: [Navigating] -> Loading dashboard UI
|
||||
dashboard_url = f"{base_ui_url.rstrip('/')}/superset/dashboard/{dashboard_id}/?standalone=true"
|
||||
|
||||
|
||||
if base_ui_url.startswith("https://") and dashboard_url.startswith("http://"):
|
||||
dashboard_url = dashboard_url.replace("http://", "https://")
|
||||
|
||||
logger.info(f"[DEBUG] Navigating to dashboard: {dashboard_url}")
|
||||
|
||||
|
||||
# Dashboard pages can keep polling/network activity open indefinitely.
|
||||
response = await self._goto_resilient(
|
||||
page,
|
||||
@@ -435,7 +439,7 @@ class ScreenshotService:
|
||||
fallback_wait_until="load",
|
||||
timeout=60000,
|
||||
)
|
||||
|
||||
|
||||
if response:
|
||||
logger.info(f"[DEBUG] Dashboard navigation response status: {response.status}, URL: {response.url}")
|
||||
|
||||
@@ -445,12 +449,12 @@ class ScreenshotService:
|
||||
raise RuntimeError(
|
||||
f"Dashboard navigation redirected to login page after authentication. Debug screenshot saved to {debug_path}"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
# Wait for the dashboard grid to be present
|
||||
await page.wait_for_selector('.dashboard-component, .dashboard-header, [data-test="dashboard-grid"]', timeout=30000)
|
||||
logger.info("[DEBUG] Dashboard container loaded")
|
||||
|
||||
|
||||
# Wait for charts to finish loading (Superset uses loading spinners/skeletons)
|
||||
# We wait until loading indicators disappear or a timeout occurs
|
||||
try:
|
||||
@@ -498,7 +502,7 @@ class ScreenshotService:
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[DEBUG] Dashboard content wait failed: {e}, proceeding anyway after delay")
|
||||
|
||||
|
||||
# Final stabilization delay - increased for complex dashboards
|
||||
logger.info("[DEBUG] Final stabilization delay...")
|
||||
await asyncio.sleep(15)
|
||||
@@ -512,42 +516,42 @@ class ScreenshotService:
|
||||
async def switch_tabs(depth=0):
|
||||
if depth > 3:
|
||||
return # Limit recursion depth
|
||||
|
||||
|
||||
tab_selectors = [
|
||||
'.ant-tabs-nav-list .ant-tabs-tab',
|
||||
'.dashboard-component-tabs .ant-tabs-tab',
|
||||
'[data-test="dashboard-component-tabs"] .ant-tabs-tab'
|
||||
]
|
||||
|
||||
|
||||
found_tabs = []
|
||||
for selector in tab_selectors:
|
||||
found_tabs = await page.locator(selector).all()
|
||||
if found_tabs:
|
||||
break
|
||||
|
||||
|
||||
if found_tabs:
|
||||
logger.info(f"[DEBUG][TabSwitching] Found {len(found_tabs)} tabs at depth {depth}")
|
||||
for i, tab in enumerate(found_tabs):
|
||||
try:
|
||||
tab_text = (await tab.inner_text()).strip()
|
||||
tab_id = f"{depth}_{i}_{tab_text}"
|
||||
|
||||
|
||||
if tab_id in processed_tabs:
|
||||
continue
|
||||
|
||||
|
||||
if await tab.is_visible():
|
||||
logger.info(f"[DEBUG][TabSwitching] Switching to tab: {tab_text}")
|
||||
processed_tabs.add(tab_id)
|
||||
|
||||
|
||||
is_active = "ant-tabs-tab-active" in (await tab.get_attribute("class") or "")
|
||||
if not is_active:
|
||||
await tab.click()
|
||||
await asyncio.sleep(2) # Wait for content to render
|
||||
|
||||
|
||||
await switch_tabs(depth + 1)
|
||||
except Exception as tab_e:
|
||||
logger.warning(f"[DEBUG][TabSwitching] Failed to process tab {i}: {tab_e}")
|
||||
|
||||
|
||||
try:
|
||||
first_tab = found_tabs[0]
|
||||
if "ant-tabs-tab-active" not in (await first_tab.get_attribute("class") or ""):
|
||||
@@ -572,7 +576,7 @@ class ScreenshotService:
|
||||
);
|
||||
}""")
|
||||
logger.info(f"[DEBUG] Calculated full height: {full_height}")
|
||||
|
||||
|
||||
# DIAGNOSTIC: Count chart elements before resize
|
||||
chart_count_before = await page.evaluate("""() => {
|
||||
return {
|
||||
@@ -583,7 +587,7 @@ class ScreenshotService:
|
||||
};
|
||||
}""")
|
||||
logger.info(f"[DIAGNOSTIC] Chart elements BEFORE viewport resize: {chart_count_before}")
|
||||
|
||||
|
||||
# DIAGNOSTIC: Capture pre-resize screenshot for comparison
|
||||
pre_resize_path = output_path.replace(".png", "_preresize.png")
|
||||
try:
|
||||
@@ -593,15 +597,15 @@ class ScreenshotService:
|
||||
logger.info(f"[DIAGNOSTIC] Pre-resize screenshot saved: {pre_resize_path} ({pre_resize_size} bytes)")
|
||||
except Exception as pre_e:
|
||||
logger.warning(f"[DIAGNOSTIC] Failed to capture pre-resize screenshot: {pre_e}")
|
||||
|
||||
|
||||
logger.info(f"[DIAGNOSTIC] Resizing viewport from current to 1920x{int(full_height)}")
|
||||
await page.set_viewport_size({"width": 1920, "height": int(full_height)})
|
||||
|
||||
|
||||
# DIAGNOSTIC: Increased wait time and log timing
|
||||
logger.info("[DIAGNOSTIC] Waiting 10 seconds after viewport resize for re-render...")
|
||||
await asyncio.sleep(10)
|
||||
logger.info("[DIAGNOSTIC] Wait completed")
|
||||
|
||||
|
||||
# DIAGNOSTIC: Count chart elements after resize and wait
|
||||
chart_count_after = await page.evaluate("""() => {
|
||||
return {
|
||||
@@ -612,7 +616,7 @@ class ScreenshotService:
|
||||
};
|
||||
}""")
|
||||
logger.info(f"[DIAGNOSTIC] Chart elements AFTER viewport resize + wait: {chart_count_after}")
|
||||
|
||||
|
||||
# DIAGNOSTIC: Check if any charts have error states
|
||||
chart_errors = await page.evaluate("""() => {
|
||||
const errors = [];
|
||||
@@ -633,31 +637,31 @@ class ScreenshotService:
|
||||
# @UX_STATE: [Capturing] -> Executing CDP screenshot
|
||||
logger.info("[DEBUG] Attempting full-page screenshot via CDP...")
|
||||
cdp = await page.context.new_cdp_session(page)
|
||||
|
||||
|
||||
screenshot_data = await cdp.send("Page.captureScreenshot", {
|
||||
"format": "png",
|
||||
"fromSurface": True,
|
||||
"captureBeyondViewport": True
|
||||
})
|
||||
|
||||
|
||||
image_data = base64.b64decode(screenshot_data["data"])
|
||||
|
||||
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(image_data)
|
||||
|
||||
|
||||
# DIAGNOSTIC: Verify screenshot file
|
||||
import os
|
||||
final_size = os.path.getsize(output_path) if os.path.exists(output_path) else 0
|
||||
logger.info(f"[DIAGNOSTIC] Final screenshot saved: {output_path}")
|
||||
logger.info(f"[DIAGNOSTIC] Final screenshot size: {final_size} bytes ({final_size / 1024:.2f} KB)")
|
||||
|
||||
|
||||
# DIAGNOSTIC: Get image dimensions
|
||||
try:
|
||||
with Image.open(output_path) as final_img:
|
||||
logger.info(f"[DIAGNOSTIC] Final screenshot dimensions: {final_img.width}x{final_img.height}")
|
||||
except Exception as img_err:
|
||||
logger.warning(f"[DIAGNOSTIC] Could not read final image dimensions: {img_err}")
|
||||
|
||||
|
||||
logger.info(f"Full-page screenshot saved to {output_path} (via CDP)")
|
||||
except Exception as e:
|
||||
logger.error(f"[DEBUG] Full-page/Tab capture failed: {e}")
|
||||
@@ -666,7 +670,7 @@ class ScreenshotService:
|
||||
except Exception as e2:
|
||||
logger.error(f"[DEBUG] Fallback screenshot also failed: {e2}")
|
||||
await page.screenshot(path=output_path, timeout=5000)
|
||||
|
||||
|
||||
await browser.close()
|
||||
return True
|
||||
# endregion ScreenshotService.capture_dashboard
|
||||
@@ -686,7 +690,7 @@ class LLMClient:
|
||||
self.api_key = normalized_key
|
||||
self.base_url = base_url
|
||||
self.default_model = default_model
|
||||
|
||||
|
||||
# DEBUG: Log initialization parameters (without exposing full API key)
|
||||
logger.info("[LLMClient.__init__] Initializing LLM client:")
|
||||
logger.info(f"[LLMClient.__init__] Provider Type: {provider_type}")
|
||||
@@ -749,14 +753,14 @@ class LLMClient:
|
||||
return False
|
||||
# Retry on rate limit errors and other exceptions
|
||||
return isinstance(exception, (RateLimitError, Exception))
|
||||
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(5),
|
||||
wait=wait_exponential(multiplier=2, min=5, max=60),
|
||||
retry=retry_if_exception(_should_retry),
|
||||
reraise=True
|
||||
)
|
||||
async def get_json_completion(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
async def get_json_completion(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
with belief_scope("get_json_completion"):
|
||||
response = None
|
||||
try:
|
||||
@@ -788,22 +792,22 @@ class LLMClient:
|
||||
or "response_format" in str(e).lower()
|
||||
or "400" in str(e)
|
||||
):
|
||||
logger.warning(f"[get_json_completion] JSON mode failed or not supported: {str(e)}. Falling back to plain text response.")
|
||||
logger.warning(f"[get_json_completion] JSON mode failed or not supported: {e!s}. Falling back to plain text response.")
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.default_model,
|
||||
messages=messages
|
||||
)
|
||||
else:
|
||||
raise e
|
||||
|
||||
|
||||
logger.debug(f"[get_json_completion] LLM Response: {response}")
|
||||
except OpenAIAuthenticationError as e:
|
||||
logger.error(f"[get_json_completion] Authentication error: {str(e)}")
|
||||
logger.error(f"[get_json_completion] Authentication error: {e!s}")
|
||||
# Do not retry on auth errors - re-raise to stop retry
|
||||
raise
|
||||
except RateLimitError as e:
|
||||
logger.warning(f"[get_json_completion] Rate limit hit: {str(e)}")
|
||||
|
||||
logger.warning(f"[get_json_completion] Rate limit hit: {e!s}")
|
||||
|
||||
# Extract retry_delay from error metadata if available
|
||||
retry_delay = 5.0 # Default fallback
|
||||
try:
|
||||
@@ -811,7 +815,7 @@ class LLMClient:
|
||||
# The logs show 'metadata': {'raw': '...'} which suggests a proxy or specific client wrapper
|
||||
# Let's try to find the 'retryDelay' in the error message or response
|
||||
import re
|
||||
|
||||
|
||||
# Try to find "retryDelay": "XXs" in the string representation of the error
|
||||
error_str = str(e)
|
||||
match = re.search(r'"retryDelay":\s*"(\d+)s"', error_str)
|
||||
@@ -829,14 +833,14 @@ class LLMClient:
|
||||
break
|
||||
except Exception as parse_e:
|
||||
logger.debug(f"[get_json_completion] Failed to parse retry delay: {parse_e}")
|
||||
|
||||
|
||||
# Add a small safety margin (0.5s) as requested
|
||||
wait_time = retry_delay + 0.5
|
||||
logger.info(f"[get_json_completion] Waiting for {wait_time}s before retry...")
|
||||
await asyncio.sleep(wait_time)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[get_json_completion] LLM call failed: {str(e)}")
|
||||
logger.error(f"[get_json_completion] LLM call failed: {e!s}")
|
||||
raise
|
||||
|
||||
if not response or not hasattr(response, 'choices') or not response.choices:
|
||||
@@ -844,7 +848,7 @@ class LLMClient:
|
||||
|
||||
content = response.choices[0].message.content
|
||||
logger.debug(f"[get_json_completion] Raw content to parse: {content}")
|
||||
|
||||
|
||||
try:
|
||||
return json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
@@ -864,7 +868,7 @@ class LLMClient:
|
||||
# @PRE: Client is initialized with provider credentials and default_model.
|
||||
# @POST: Returns lightweight JSON payload when runtime auth/model path is valid.
|
||||
# @SIDE_EFFECT: Calls external LLM API.
|
||||
async def test_runtime_connection(self) -> Dict[str, Any]:
|
||||
async def test_runtime_connection(self) -> dict[str, Any]:
|
||||
with belief_scope("test_runtime_connection"):
|
||||
messages = [
|
||||
{
|
||||
@@ -880,7 +884,7 @@ class LLMClient:
|
||||
# @PRE: Client is initialized with provider credentials.
|
||||
# @POST: Returns a list of model ID strings.
|
||||
# @SIDE_EFFECT: Calls external LLM API /v1/models endpoint.
|
||||
async def fetch_models(self) -> List[str]:
|
||||
async def fetch_models(self) -> list[str]:
|
||||
with belief_scope("LLMClient.fetch_models"):
|
||||
try:
|
||||
response = await self.client.models.list()
|
||||
@@ -906,9 +910,9 @@ class LLMClient:
|
||||
async def analyze_dashboard(
|
||||
self,
|
||||
screenshot_path: str,
|
||||
logs: List[str],
|
||||
logs: list[str],
|
||||
prompt_template: str = DEFAULT_LLM_PROMPTS["dashboard_validation_prompt"],
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
with belief_scope("analyze_dashboard"):
|
||||
# Optimize image to reduce token count (US1 / T023)
|
||||
# Gemini/Gemma models have limits on input tokens, and large images contribute significantly.
|
||||
@@ -917,7 +921,7 @@ class LLMClient:
|
||||
# Convert to RGB if necessary
|
||||
if img.mode in ("RGBA", "P"):
|
||||
img = img.convert("RGB")
|
||||
|
||||
|
||||
# Resize if too large (max 1024px width while maintaining aspect ratio)
|
||||
# We reduce width further to 1024px to stay within token limits for long dashboards
|
||||
max_width = 1024
|
||||
@@ -929,7 +933,7 @@ class LLMClient:
|
||||
new_height = int(img.height * scale)
|
||||
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
logger.info(f"[analyze_dashboard] Resized image from {img.width}x{img.height} to {new_width}x{new_height}")
|
||||
|
||||
|
||||
# Compress and convert to base64
|
||||
buffer = io.BytesIO()
|
||||
# Lower quality to 60% to further reduce payload size
|
||||
@@ -943,7 +947,7 @@ class LLMClient:
|
||||
|
||||
log_text = "\n".join(logs)
|
||||
prompt = render_prompt(prompt_template, {"logs": log_text})
|
||||
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
@@ -958,14 +962,14 @@ class LLMClient:
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
try:
|
||||
return await self.get_json_completion(messages)
|
||||
except Exception as e:
|
||||
logger.error(f"[analyze_dashboard] Failed to get analysis: {str(e)}")
|
||||
logger.error(f"[analyze_dashboard] Failed to get analysis: {e!s}")
|
||||
return {
|
||||
"status": "UNKNOWN",
|
||||
"summary": f"Failed to get response from LLM: {str(e)}",
|
||||
"summary": f"Failed to get response from LLM: {e!s}",
|
||||
"issues": [{"severity": "UNKNOWN", "message": "LLM provider returned empty or invalid response"}]
|
||||
}
|
||||
# endregion LLMClient.analyze_dashboard
|
||||
|
||||
@@ -4,14 +4,16 @@
|
||||
# @RELATION Inherits from PluginBase. Uses DatasetMapper from superset_tool.
|
||||
# @RELATION USES -> TaskContext
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Any
|
||||
|
||||
from ..core.database import SessionLocal
|
||||
from ..core.logger import belief_scope, logger
|
||||
from ..core.plugin_base import PluginBase
|
||||
from ..core.superset_client import SupersetClient
|
||||
from ..core.logger import logger, belief_scope
|
||||
from ..core.database import SessionLocal
|
||||
from ..models.connection import ConnectionConfig
|
||||
from ..core.utils.dataset_mapper import DatasetMapper
|
||||
from ..core.task_manager.context import TaskContext
|
||||
from ..core.utils.dataset_mapper import DatasetMapper
|
||||
from ..models.connection import ConnectionConfig
|
||||
|
||||
|
||||
# #region MapperPlugin [TYPE Class]
|
||||
# @BRIEF Plugin for mapping dataset columns verbose names.
|
||||
@@ -78,7 +80,7 @@ class MapperPlugin(PluginBase):
|
||||
# @PRE: Plugin instance exists.
|
||||
# @POST: Returns dictionary schema.
|
||||
# @RETURN: Dict[str, Any] - JSON schema.
|
||||
def get_schema(self) -> Dict[str, Any]:
|
||||
def get_schema(self) -> dict[str, Any]:
|
||||
with belief_scope("get_schema"):
|
||||
return {
|
||||
"type": "object",
|
||||
@@ -132,19 +134,19 @@ class MapperPlugin(PluginBase):
|
||||
# @PRE: Params contain valid 'env', 'dataset_id', and 'source'. params must be a dictionary.
|
||||
# @POST: Updates the dataset in Superset.
|
||||
# @RETURN: Dict[str, Any] - Execution status.
|
||||
async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None) -> Dict[str, Any]:
|
||||
async def execute(self, params: dict[str, Any], context: TaskContext | None = None) -> dict[str, Any]:
|
||||
with belief_scope("execute"):
|
||||
env_name = params.get("env")
|
||||
dataset_id = params.get("dataset_id")
|
||||
source = params.get("source")
|
||||
|
||||
|
||||
# Use TaskContext logger if available, otherwise fall back to app logger
|
||||
log = context.logger if context else logger
|
||||
|
||||
|
||||
# Create sub-loggers for different components
|
||||
superset_log = log.with_source("superset_api") if context else log
|
||||
db_log = log.with_source("postgres") if context else log
|
||||
|
||||
|
||||
if not env_name or dataset_id is None or not source:
|
||||
log.error("Missing required parameters: env, dataset_id, source")
|
||||
raise ValueError("Missing required parameters: env, dataset_id, source")
|
||||
@@ -166,7 +168,7 @@ class MapperPlugin(PluginBase):
|
||||
if not connection_id:
|
||||
log.error("connection_id is required for postgres source.")
|
||||
raise ValueError("connection_id is required for postgres source.")
|
||||
|
||||
|
||||
# Load connection from DB
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -174,7 +176,7 @@ class MapperPlugin(PluginBase):
|
||||
if not conn_config:
|
||||
db_log.error(f"Connection {connection_id} not found.")
|
||||
raise ValueError(f"Connection {connection_id} not found.")
|
||||
|
||||
|
||||
postgres_config = {
|
||||
'dbname': conn_config.database,
|
||||
'user': conn_config.username,
|
||||
@@ -187,9 +189,9 @@ class MapperPlugin(PluginBase):
|
||||
db.close()
|
||||
|
||||
log.info(f"Starting mapping for dataset {dataset_id} in {env_name}")
|
||||
|
||||
|
||||
mapper = DatasetMapper()
|
||||
|
||||
|
||||
try:
|
||||
mapper.run_mapping(
|
||||
superset_client=client,
|
||||
@@ -208,4 +210,4 @@ class MapperPlugin(PluginBase):
|
||||
# endregion execute
|
||||
|
||||
# #endregion MapperPlugin
|
||||
# #endregion MapperPluginModule
|
||||
# #endregion MapperPluginModule
|
||||
|
||||
@@ -12,19 +12,21 @@
|
||||
# @DATA_CONTRACT: Input[TaskContext{from_env,to_env,dashboard_regex,replace_db_config,from_db_id,to_db_id,passwords?}] -> Output[MigrationResult|artifact set]
|
||||
# @INVARIANT: Dashboards must never be imported with unmapped/source DB connections to prevent data leaks or cross-environment pollution.
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from ..core.database import SessionLocal
|
||||
from ..core.logger import belief_scope
|
||||
from ..core.logger import logger as app_logger
|
||||
from ..core.mapping_service import IdMappingService
|
||||
from ..core.migration_engine import MigrationEngine
|
||||
from ..core.plugin_base import PluginBase
|
||||
from ..core.logger import belief_scope, logger as app_logger
|
||||
from ..core.superset_client import SupersetClient
|
||||
from ..core.task_manager.context import TaskContext
|
||||
from ..core.utils.fileio import create_temp_file
|
||||
from ..dependencies import get_config_manager
|
||||
from ..core.migration_engine import MigrationEngine
|
||||
from ..core.database import SessionLocal
|
||||
from ..models.mapping import DatabaseMapping, Environment
|
||||
from ..core.mapping_service import IdMappingService
|
||||
from ..core.task_manager.context import TaskContext
|
||||
|
||||
|
||||
# #region MigrationPlugin [TYPE Class]
|
||||
# @BRIEF Implementation of the migration plugin workflow and transformation orchestration.
|
||||
@@ -101,12 +103,12 @@ class MigrationPlugin(PluginBase):
|
||||
# @PRE: ConfigManager is accessible and environments are defined.
|
||||
# @POST: Returns a JSON Schema dict matching current system environments.
|
||||
# @RETURN: Dict[str, Any]
|
||||
def get_schema(self) -> Dict[str, Any]:
|
||||
def get_schema(self) -> dict[str, Any]:
|
||||
with belief_scope("MigrationPlugin.get_schema"):
|
||||
app_logger.reason("Generating migration UI schema")
|
||||
config_manager = get_config_manager()
|
||||
envs = [e.name for e in config_manager.get_environments()]
|
||||
|
||||
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -164,20 +166,20 @@ class MigrationPlugin(PluginBase):
|
||||
# @TEST_EDGE: missing_env_field -> [ValueError: Could not resolve source or target environment]
|
||||
# @TEST_EDGE: invalid_regex_pattern -> [Regex compilation exception is thrown or caught gracefully]
|
||||
# @TEST_EDGE: target_api_timeout -> [Dashboard added to failed_dashboards, task concludes with PARTIAL_SUCCESS]
|
||||
async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None):
|
||||
async def execute(self, params: dict[str, Any], context: TaskContext | None = None):
|
||||
with belief_scope("MigrationPlugin.execute"):
|
||||
app_logger.reason("Evaluating migration task parameters", extra={"params": params})
|
||||
|
||||
|
||||
source_env_id = params.get("source_env_id")
|
||||
target_env_id = params.get("target_env_id")
|
||||
selected_ids = params.get("selected_ids")
|
||||
|
||||
|
||||
from_env_name = params.get("from_env")
|
||||
to_env_name = params.get("to_env")
|
||||
dashboard_regex = params.get("dashboard_regex")
|
||||
replace_db_config = params.get("replace_db_config", False)
|
||||
fix_cross_filters = params.get("fix_cross_filters", True)
|
||||
|
||||
|
||||
task_id = params.get("_task_id")
|
||||
from ..dependencies import get_task_manager
|
||||
tm = get_task_manager()
|
||||
@@ -185,17 +187,17 @@ class MigrationPlugin(PluginBase):
|
||||
log = context.logger if context else app_logger
|
||||
superset_log = log.with_source("superset_api") if context else log
|
||||
migration_log = log.with_source("migration") if context else log
|
||||
|
||||
|
||||
log.info("Starting migration task.")
|
||||
|
||||
try:
|
||||
config_manager = get_config_manager()
|
||||
environments = config_manager.get_environments()
|
||||
|
||||
|
||||
# Resolve environments
|
||||
src_env = next((e for e in environments if e.id == source_env_id), None) if source_env_id else next((e for e in environments if e.name == from_env_name), None)
|
||||
tgt_env = next((e for e in environments if e.id == target_env_id), None) if target_env_id else next((e for e in environments if e.name == to_env_name), None)
|
||||
|
||||
|
||||
if not src_env or not tgt_env:
|
||||
app_logger.explore("Environment resolution failed", extra={"src": source_env_id or from_env_name, "tgt": target_env_id or to_env_name})
|
||||
raise ValueError(f"Could not resolve source or target environment. Source: {source_env_id or from_env_name}, Target: {target_env_id or to_env_name}")
|
||||
@@ -204,7 +206,7 @@ class MigrationPlugin(PluginBase):
|
||||
to_env_name = tgt_env.name
|
||||
|
||||
app_logger.reason("Environments resolved successfully", extra={"from": from_env_name, "to": to_env_name})
|
||||
|
||||
|
||||
migration_result = {
|
||||
"status": "SUCCESS",
|
||||
"source_environment": from_env_name,
|
||||
@@ -217,12 +219,12 @@ class MigrationPlugin(PluginBase):
|
||||
|
||||
from_c = SupersetClient(src_env)
|
||||
to_c = SupersetClient(tgt_env)
|
||||
|
||||
|
||||
if not from_c or not to_c:
|
||||
raise ValueError(f"Clients not initialized for environments: {from_env_name}, {to_env_name}")
|
||||
|
||||
_, all_dashboards = from_c.get_dashboards()
|
||||
|
||||
|
||||
# Selection Logic
|
||||
if selected_ids:
|
||||
dashboards_to_migrate = [d for d in all_dashboards if d["id"] in selected_ids]
|
||||
@@ -245,14 +247,14 @@ class MigrationPlugin(PluginBase):
|
||||
db_mapping = params.get("db_mappings", {})
|
||||
if not isinstance(db_mapping, dict):
|
||||
db_mapping = {}
|
||||
|
||||
|
||||
if replace_db_config:
|
||||
app_logger.reason("Fetching environment DB mappings from catalog")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
src_env_db = db.query(Environment).filter(Environment.name == from_env_name).first()
|
||||
tgt_env_db = db.query(Environment).filter(Environment.name == to_env_name).first()
|
||||
|
||||
|
||||
if src_env_db and tgt_env_db:
|
||||
stored_mappings = db.query(DatabaseMapping).filter(
|
||||
DatabaseMapping.source_env_id == src_env_db.id,
|
||||
@@ -272,60 +274,59 @@ class MigrationPlugin(PluginBase):
|
||||
for dash in dashboards_to_migrate:
|
||||
dash_id, dash_slug, title = dash["id"], dash.get("slug"), dash["dashboard_title"]
|
||||
app_logger.reason(f"Starting pipeline for dashboard '{title}'", extra={"dash_id": dash_id})
|
||||
|
||||
|
||||
try:
|
||||
exported_content, _ = from_c.export_dashboard(dash_id)
|
||||
with create_temp_file(content=exported_content, dry_run=True, suffix=".zip") as tmp_zip_path:
|
||||
with create_temp_file(suffix=".zip", dry_run=True) as tmp_new_zip:
|
||||
|
||||
success = engine.transform_zip(
|
||||
str(tmp_zip_path),
|
||||
str(tmp_new_zip),
|
||||
db_mapping,
|
||||
strip_databases=False,
|
||||
target_env_id=tgt_env.id if tgt_env else None,
|
||||
fix_cross_filters=fix_cross_filters
|
||||
)
|
||||
|
||||
if not success and replace_db_config:
|
||||
if task_id:
|
||||
app_logger.explore("Missing mapping blocks AST transform. Pausing task for user intervention.", extra={"task_id": task_id})
|
||||
await tm.wait_for_resolution(task_id)
|
||||
|
||||
app_logger.reason("Task resumed, re-evaluating mapping states")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
src_env_rt = db.query(Environment).filter(Environment.name == from_env_name).first()
|
||||
tgt_env_rt = db.query(Environment).filter(Environment.name == to_env_name).first()
|
||||
mappings = db.query(DatabaseMapping).filter(
|
||||
DatabaseMapping.source_env_id == src_env_rt.id,
|
||||
DatabaseMapping.target_env_id == tgt_env_rt.id
|
||||
).all()
|
||||
db_mapping = {m.source_db_uuid: m.target_db_uuid for m in mappings}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
success = engine.transform_zip(
|
||||
str(tmp_zip_path),
|
||||
str(tmp_new_zip),
|
||||
db_mapping,
|
||||
strip_databases=False,
|
||||
target_env_id=tgt_env.id if tgt_env else None,
|
||||
fix_cross_filters=fix_cross_filters
|
||||
)
|
||||
with create_temp_file(content=exported_content, dry_run=True, suffix=".zip") as tmp_zip_path, create_temp_file(suffix=".zip", dry_run=True) as tmp_new_zip:
|
||||
|
||||
success = engine.transform_zip(
|
||||
str(tmp_zip_path),
|
||||
str(tmp_new_zip),
|
||||
db_mapping,
|
||||
strip_databases=False,
|
||||
target_env_id=tgt_env.id if tgt_env else None,
|
||||
fix_cross_filters=fix_cross_filters
|
||||
)
|
||||
|
||||
if not success and replace_db_config:
|
||||
if task_id:
|
||||
app_logger.explore("Missing mapping blocks AST transform. Pausing task for user intervention.", extra={"task_id": task_id})
|
||||
await tm.wait_for_resolution(task_id)
|
||||
|
||||
app_logger.reason("Task resumed, re-evaluating mapping states")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
src_env_rt = db.query(Environment).filter(Environment.name == from_env_name).first()
|
||||
tgt_env_rt = db.query(Environment).filter(Environment.name == to_env_name).first()
|
||||
mappings = db.query(DatabaseMapping).filter(
|
||||
DatabaseMapping.source_env_id == src_env_rt.id,
|
||||
DatabaseMapping.target_env_id == tgt_env_rt.id
|
||||
).all()
|
||||
db_mapping = {m.source_db_uuid: m.target_db_uuid for m in mappings}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
success = engine.transform_zip(
|
||||
str(tmp_zip_path),
|
||||
str(tmp_new_zip),
|
||||
db_mapping,
|
||||
strip_databases=False,
|
||||
target_env_id=tgt_env.id if tgt_env else None,
|
||||
fix_cross_filters=fix_cross_filters
|
||||
)
|
||||
|
||||
if success:
|
||||
app_logger.reason("Pushing transformed ZIP to target Superset")
|
||||
to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug)
|
||||
migration_result["migrated_dashboards"].append({"id": dash_id, "title": title})
|
||||
app_logger.reflect("Import successful", extra={"title": title})
|
||||
else:
|
||||
app_logger.explore("Transformation strictly failed, bypassing ingestion")
|
||||
migration_log.error(f"Failed to transform ZIP for dashboard {title}")
|
||||
migration_result["failed_dashboards"].append({
|
||||
"id": dash_id, "title": title, "error": "Failed to transform ZIP"
|
||||
})
|
||||
|
||||
if success:
|
||||
app_logger.reason("Pushing transformed ZIP to target Superset")
|
||||
to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug)
|
||||
migration_result["migrated_dashboards"].append({"id": dash_id, "title": title})
|
||||
app_logger.reflect("Import successful", extra={"title": title})
|
||||
else:
|
||||
app_logger.explore("Transformation strictly failed, bypassing ingestion")
|
||||
migration_log.error(f"Failed to transform ZIP for dashboard {title}")
|
||||
migration_result["failed_dashboards"].append({
|
||||
"id": dash_id, "title": title, "error": "Failed to transform ZIP"
|
||||
})
|
||||
|
||||
except Exception as exc:
|
||||
error_msg = str(exc)
|
||||
if "Must provide a password for the database" in error_msg:
|
||||
@@ -338,19 +339,19 @@ class MigrationPlugin(PluginBase):
|
||||
if match_alt:
|
||||
db_name = match_alt.group(1)
|
||||
|
||||
app_logger.explore(f"Missing DB password detected during ingestion. Escalating to UI.", extra={"db_name": db_name})
|
||||
|
||||
app_logger.explore("Missing DB password detected during ingestion. Escalating to UI.", extra={"db_name": db_name})
|
||||
|
||||
if task_id:
|
||||
tm.await_input(task_id, {
|
||||
"type": "database_password",
|
||||
"databases": [db_name],
|
||||
"error_message": error_msg
|
||||
})
|
||||
|
||||
|
||||
await tm.wait_for_input(task_id)
|
||||
task = tm.get_task(task_id)
|
||||
passwords = task.params.get("passwords", {})
|
||||
|
||||
|
||||
if passwords:
|
||||
app_logger.reason(f"Retrying import for {title} with injected credentials")
|
||||
to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug, passwords=passwords)
|
||||
@@ -385,4 +386,4 @@ class MigrationPlugin(PluginBase):
|
||||
raise e
|
||||
# endregion execute
|
||||
# #endregion MigrationPlugin
|
||||
# #endregion MigrationPlugin
|
||||
# #endregion MigrationPlugin
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
# @RELATION USES -> TaskContext
|
||||
|
||||
import re
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Any
|
||||
|
||||
from ..core.logger import belief_scope, logger
|
||||
from ..core.plugin_base import PluginBase
|
||||
from ..core.superset_client import SupersetClient
|
||||
from ..core.logger import logger, belief_scope
|
||||
from ..core.task_manager.context import TaskContext
|
||||
|
||||
|
||||
# #region SearchPlugin [TYPE Class]
|
||||
# @BRIEF Plugin for searching text patterns in Superset datasets.
|
||||
class SearchPlugin(PluginBase):
|
||||
@@ -76,7 +78,7 @@ class SearchPlugin(PluginBase):
|
||||
# @PRE: Plugin instance exists.
|
||||
# @POST: Returns dictionary schema.
|
||||
# @RETURN: Dict[str, Any] - JSON schema.
|
||||
def get_schema(self) -> Dict[str, Any]:
|
||||
def get_schema(self) -> dict[str, Any]:
|
||||
with belief_scope("get_schema"):
|
||||
return {
|
||||
"type": "object",
|
||||
@@ -103,18 +105,18 @@ class SearchPlugin(PluginBase):
|
||||
# @PRE: Params contain valid 'env' and 'query'.
|
||||
# @POST: Returns a dictionary with count and results list.
|
||||
# @RETURN: Dict[str, Any] - Search results.
|
||||
async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None) -> Dict[str, Any]:
|
||||
async def execute(self, params: dict[str, Any], context: TaskContext | None = None) -> dict[str, Any]:
|
||||
with belief_scope("SearchPlugin.execute", f"params={params}"):
|
||||
env_name = params.get("env")
|
||||
search_query = params.get("query")
|
||||
|
||||
|
||||
# Use TaskContext logger if available, otherwise fall back to app logger
|
||||
log = context.logger if context else logger
|
||||
|
||||
|
||||
# Create sub-loggers for different components
|
||||
log.with_source("superset_api") if context else log
|
||||
search_log = log.with_source("search") if context else log
|
||||
|
||||
|
||||
if not env_name or not search_query:
|
||||
log.error("Missing required parameters: env, query")
|
||||
raise ValueError("Missing required parameters: env, query")
|
||||
@@ -131,7 +133,7 @@ class SearchPlugin(PluginBase):
|
||||
client.authenticate()
|
||||
|
||||
log.info(f"Searching for pattern: '{search_query}' in environment: {env_name}")
|
||||
|
||||
|
||||
try:
|
||||
# Ported logic from search_script.py
|
||||
_, datasets = client.get_datasets(query={"columns": ["id", "table_name", "sql", "database", "columns"]})
|
||||
@@ -142,7 +144,7 @@ class SearchPlugin(PluginBase):
|
||||
|
||||
pattern = re.compile(search_query, re.IGNORECASE)
|
||||
results = []
|
||||
|
||||
|
||||
for dataset in datasets:
|
||||
dataset_id = dataset.get('id')
|
||||
dataset_name = dataset.get('table_name', 'Unknown')
|
||||
@@ -190,14 +192,14 @@ class SearchPlugin(PluginBase):
|
||||
with belief_scope("_get_context"):
|
||||
if not match_text:
|
||||
return text[:100] + "..." if len(text) > 100 else text
|
||||
|
||||
|
||||
lines = text.splitlines()
|
||||
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 = max(0, match_line_index - context_lines)
|
||||
end = min(len(lines), match_line_index + context_lines + 1)
|
||||
@@ -209,9 +211,9 @@ class SearchPlugin(PluginBase):
|
||||
else:
|
||||
context.append(f" {line_content}")
|
||||
return "\n".join(context)
|
||||
|
||||
|
||||
return text[:100] + "..." if len(text) > 100 else text
|
||||
# endregion _get_context
|
||||
|
||||
# #endregion SearchPlugin
|
||||
# #endregion SearchPluginModule
|
||||
# #endregion SearchPluginModule
|
||||
|
||||
@@ -10,16 +10,18 @@
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import UploadFile
|
||||
|
||||
from ...core.plugin_base import PluginBase
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...models.storage import StoredFile, FileCategory
|
||||
from ...dependencies import get_config_manager
|
||||
from ...core.plugin_base import PluginBase
|
||||
from ...core.task_manager.context import TaskContext
|
||||
from ...dependencies import get_config_manager
|
||||
from ...models.storage import FileCategory, StoredFile
|
||||
|
||||
|
||||
# #region StoragePlugin [TYPE Class]
|
||||
# @BRIEF Implementation of the storage management plugin.
|
||||
@@ -95,7 +97,7 @@ class StoragePlugin(PluginBase):
|
||||
# @PRE: None.
|
||||
# @POST: Returns a dictionary representing the JSON schema.
|
||||
# @RETURN: Dict[str, Any] - JSON schema.
|
||||
def get_schema(self) -> Dict[str, Any]:
|
||||
def get_schema(self) -> dict[str, Any]:
|
||||
with belief_scope("StoragePlugin:get_schema"):
|
||||
return {
|
||||
"type": "object",
|
||||
@@ -116,15 +118,15 @@ class StoragePlugin(PluginBase):
|
||||
# @PARAM: context (Optional[TaskContext]) - Task context for logging with source attribution.
|
||||
# @PRE: params must match the plugin schema.
|
||||
# @POST: Task is executed and logged.
|
||||
async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None):
|
||||
async def execute(self, params: dict[str, Any], context: TaskContext | None = None):
|
||||
with belief_scope("StoragePlugin:execute"):
|
||||
# Use TaskContext logger if available, otherwise fall back to app logger
|
||||
log = context.logger if context else logger
|
||||
|
||||
|
||||
# Create sub-loggers for different components
|
||||
storage_log = log.with_source("storage") if context else log
|
||||
log.with_source("filesystem") if context else log
|
||||
|
||||
|
||||
storage_log.info(f"Executing with params: {params}")
|
||||
# endregion execute
|
||||
|
||||
@@ -136,10 +138,10 @@ class StoragePlugin(PluginBase):
|
||||
with belief_scope("StoragePlugin:get_storage_root"):
|
||||
config_manager = get_config_manager()
|
||||
global_settings = config_manager.get_config().settings
|
||||
|
||||
|
||||
# Use storage.root_path as the source of truth for storage UI
|
||||
root = Path(global_settings.storage.root_path)
|
||||
|
||||
|
||||
if not root.is_absolute():
|
||||
# Resolve relative to the backend directory
|
||||
# Path(__file__) is backend/src/plugins/storage/plugin.py
|
||||
@@ -157,7 +159,7 @@ class StoragePlugin(PluginBase):
|
||||
# @PRE: pattern must be a valid format string.
|
||||
# @POST: Returns the resolved path string.
|
||||
# @RETURN: str - The resolved path.
|
||||
def resolve_path(self, pattern: str, variables: Dict[str, str]) -> str:
|
||||
def resolve_path(self, pattern: str, variables: dict[str, str]) -> str:
|
||||
with belief_scope("StoragePlugin:resolve_path"):
|
||||
# Add common variables
|
||||
vars_with_defaults = {
|
||||
@@ -215,10 +217,10 @@ class StoragePlugin(PluginBase):
|
||||
# @RETURN: List[StoredFile] - List of file and directory metadata objects.
|
||||
def list_files(
|
||||
self,
|
||||
category: Optional[FileCategory] = None,
|
||||
subpath: Optional[str] = None,
|
||||
category: FileCategory | None = None,
|
||||
subpath: str | None = None,
|
||||
recursive: bool = False,
|
||||
) -> List[StoredFile]:
|
||||
) -> list[StoredFile]:
|
||||
with belief_scope("StoragePlugin:list_files"):
|
||||
root = self.get_storage_root()
|
||||
logger.reason(
|
||||
@@ -245,9 +247,9 @@ class StoragePlugin(PluginBase):
|
||||
)
|
||||
)
|
||||
return sorted(files, key=lambda x: x.name)
|
||||
|
||||
|
||||
categories = [category] if category else list(FileCategory)
|
||||
|
||||
|
||||
for cat in categories:
|
||||
# Scan the category subfolder + optional subpath
|
||||
base_dir = root / cat.value
|
||||
@@ -258,7 +260,7 @@ class StoragePlugin(PluginBase):
|
||||
|
||||
if not target_dir.exists():
|
||||
continue
|
||||
|
||||
|
||||
logger.reason(f"Scanning directory: {target_dir}", extra={"src": "StoragePlugin.list_files"})
|
||||
|
||||
if recursive:
|
||||
@@ -299,7 +301,7 @@ class StoragePlugin(PluginBase):
|
||||
category=cat,
|
||||
mime_type="directory" if is_dir else None
|
||||
))
|
||||
|
||||
|
||||
# Sort: directories first, then by name
|
||||
return sorted(files, key=lambda x: (x.mime_type != "directory", x.name))
|
||||
# endregion list_files
|
||||
@@ -313,20 +315,20 @@ class StoragePlugin(PluginBase):
|
||||
# @POST: File is written to disk and metadata is returned.
|
||||
# @RETURN: StoredFile - Metadata of the saved file.
|
||||
# @SIDE_EFFECT: Writes file to disk.
|
||||
async def save_file(self, file: UploadFile, category: FileCategory, subpath: Optional[str] = None) -> StoredFile:
|
||||
async def save_file(self, file: UploadFile, category: FileCategory, subpath: str | None = None) -> StoredFile:
|
||||
with belief_scope("StoragePlugin:save_file"):
|
||||
root = self.get_storage_root()
|
||||
dest_dir = root / category.value
|
||||
if subpath:
|
||||
dest_dir = dest_dir / subpath
|
||||
|
||||
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
dest_path = self.validate_path(dest_dir / file.filename)
|
||||
|
||||
|
||||
with dest_path.open("wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
|
||||
stat = dest_path.stat()
|
||||
return StoredFile(
|
||||
name=dest_path.name,
|
||||
@@ -350,7 +352,7 @@ class StoragePlugin(PluginBase):
|
||||
root = self.get_storage_root()
|
||||
# path is relative to root, but we ensure it starts with category
|
||||
full_path = self.validate_path(root / path)
|
||||
|
||||
|
||||
if not str(Path(path)).startswith(category.value):
|
||||
raise ValueError(f"Path {path} does not belong to category {category}")
|
||||
|
||||
@@ -375,13 +377,13 @@ class StoragePlugin(PluginBase):
|
||||
with belief_scope("StoragePlugin:get_file_path"):
|
||||
root = self.get_storage_root()
|
||||
file_path = self.validate_path(root / path)
|
||||
|
||||
|
||||
if not str(Path(path)).startswith(category.value):
|
||||
raise ValueError(f"Path {path} does not belong to category {category}")
|
||||
|
||||
if not file_path.exists() or file_path.is_dir():
|
||||
raise FileNotFoundError(f"File {path} not found")
|
||||
|
||||
|
||||
return file_path
|
||||
# endregion get_file_path
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
from src.plugins.translate.sql_generator import (
|
||||
SQLGenerator,
|
||||
_normalize_timestamp_value,
|
||||
_encode_sql_value,
|
||||
_normalize_timestamp_value,
|
||||
)
|
||||
|
||||
|
||||
@@ -363,22 +363,21 @@ class TestOrchestratorInsertFlow:
|
||||
orch = TranslationOrchestrator(db, config_manager, "test-user")
|
||||
|
||||
# Mock the event_log and SupersetSqlLabExecutor
|
||||
with patch.object(orch, "event_log") as mock_event_log:
|
||||
with patch(
|
||||
"src.plugins.translate.orchestrator.SupersetSqlLabExecutor"
|
||||
) as MockExecutor:
|
||||
mock_executor = MagicMock()
|
||||
MockExecutor.return_value = mock_executor
|
||||
mock_executor.resolve_database_id.return_value = None
|
||||
mock_executor.get_database_backend.return_value = None
|
||||
mock_executor.execute_and_poll.return_value = {
|
||||
"status": "success",
|
||||
"query_id": "q-123",
|
||||
"rows_affected": 2,
|
||||
}
|
||||
with patch.object(orch, "event_log") as mock_event_log, patch(
|
||||
"src.plugins.translate.orchestrator.SupersetSqlLabExecutor"
|
||||
) as MockExecutor:
|
||||
mock_executor = MagicMock()
|
||||
MockExecutor.return_value = mock_executor
|
||||
mock_executor.resolve_database_id.return_value = None
|
||||
mock_executor.get_database_backend.return_value = None
|
||||
mock_executor.execute_and_poll.return_value = {
|
||||
"status": "success",
|
||||
"query_id": "q-123",
|
||||
"rows_affected": 2,
|
||||
}
|
||||
|
||||
# Call _generate_and_insert_sql
|
||||
result = orch._generate_and_insert_sql(job, run)
|
||||
# Call _generate_and_insert_sql
|
||||
result = orch._generate_and_insert_sql(job, run)
|
||||
|
||||
# Verify executor was called
|
||||
mock_executor.execute_and_poll.assert_called_once()
|
||||
|
||||
@@ -17,23 +17,18 @@
|
||||
# @TEST_EDGE: import_invalid_format -> ValueError for missing columns
|
||||
# @TEST_INVARIANT: unique_normalized -> verifies: [duplicate_entry]
|
||||
|
||||
|
||||
import pytest
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from src.models.translate import (
|
||||
Base,
|
||||
TerminologyDictionary,
|
||||
DictionaryEntry,
|
||||
TranslationJob,
|
||||
TranslationJobDictionary,
|
||||
TranslationRun,
|
||||
)
|
||||
from src.plugins.translate._utils import _detect_delimiter, _normalize_term
|
||||
from src.plugins.translate.dictionary import DictionaryManager
|
||||
from src.plugins.translate._utils import _normalize_term, _detect_delimiter
|
||||
|
||||
|
||||
# region _FakeJob [TYPE Class]
|
||||
|
||||
@@ -11,18 +11,19 @@
|
||||
# @TEST_EDGE: invalid_run_status -> raises ValueError
|
||||
# @TEST_EDGE: executor_failure -> run is marked FAILED
|
||||
|
||||
import pytest
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from src.plugins.translate.orchestrator import TranslationOrchestrator
|
||||
from src.plugins.translate.events import TranslationEventLog
|
||||
import pytest
|
||||
|
||||
from src.models.translate import (
|
||||
TranslationJob,
|
||||
TranslationRun,
|
||||
TranslationRecord,
|
||||
TranslationPreviewSession,
|
||||
TranslationRecord,
|
||||
TranslationRun,
|
||||
)
|
||||
from src.plugins.translate.events import TranslationEventLog
|
||||
from src.plugins.translate.orchestrator import TranslationOrchestrator
|
||||
|
||||
|
||||
# region mock_job [TYPE Function]
|
||||
@@ -63,7 +64,7 @@ def mock_preview_session() -> MagicMock:
|
||||
session.id = "session-1"
|
||||
session.job_id = "job-123"
|
||||
session.status = "APPLIED"
|
||||
session.created_at = datetime.now(timezone.utc)
|
||||
session.created_at = datetime.now(UTC)
|
||||
return session
|
||||
|
||||
|
||||
@@ -251,7 +252,7 @@ class TestTranslationOrchestrator:
|
||||
completed_run_response.successful_records = 10
|
||||
completed_run_response.failed_records = 0
|
||||
completed_run_response.skipped_records = 0
|
||||
completed_run_response.completed_at = datetime.now(timezone.utc)
|
||||
completed_run_response.completed_at = datetime.now(UTC)
|
||||
completed_run_response.error_message = None
|
||||
completed_run_response.insert_status = "success"
|
||||
completed_run_response.superset_execution_id = "q-1"
|
||||
@@ -276,12 +277,11 @@ class TestTranslationOrchestrator:
|
||||
MockExecutor.return_value = mock_executor_instance
|
||||
|
||||
orch = TranslationOrchestrator(db, config_manager, "test-user")
|
||||
with patch.object(orch, "event_log"):
|
||||
with patch.object(
|
||||
orch, "_generate_and_insert_sql",
|
||||
return_value={"status": "success", "query_id": "q-1", "rows_affected": 10},
|
||||
):
|
||||
result = orch.execute_run(run)
|
||||
with patch.object(orch, "event_log"), patch.object(
|
||||
orch, "_generate_and_insert_sql",
|
||||
return_value={"status": "success", "query_id": "q-1", "rows_affected": 10},
|
||||
):
|
||||
result = orch.execute_run(run)
|
||||
|
||||
assert result.status == "COMPLETED"
|
||||
assert result.insert_status == "success"
|
||||
@@ -346,7 +346,7 @@ class TestTranslationOrchestrator:
|
||||
completed_run.successful_records = 5
|
||||
completed_run.failed_records = 0
|
||||
completed_run.skipped_records = 0
|
||||
completed_run.completed_at = datetime.now(timezone.utc)
|
||||
completed_run.completed_at = datetime.now(UTC)
|
||||
completed_run.error_message = None
|
||||
|
||||
with patch(
|
||||
@@ -357,9 +357,8 @@ class TestTranslationOrchestrator:
|
||||
MockExecutor.return_value = mock_executor_instance
|
||||
|
||||
orch = TranslationOrchestrator(db, config_manager, "test-user")
|
||||
with patch.object(orch, "_generate_and_insert_sql") as mock_gen_sql:
|
||||
with patch.object(orch, "event_log"):
|
||||
result = orch.execute_run(run, skip_insert=True)
|
||||
with patch.object(orch, "_generate_and_insert_sql") as mock_gen_sql, patch.object(orch, "event_log"):
|
||||
result = orch.execute_run(run, skip_insert=True)
|
||||
|
||||
assert result.status == "COMPLETED"
|
||||
# _generate_and_insert_sql should NOT be called in skip_insert mode
|
||||
@@ -403,7 +402,7 @@ class TestTranslationOrchestrator:
|
||||
completed_run_response.successful_records = 3
|
||||
completed_run_response.failed_records = 0
|
||||
completed_run_response.skipped_records = 0
|
||||
completed_run_response.completed_at = datetime.now(timezone.utc)
|
||||
completed_run_response.completed_at = datetime.now(UTC)
|
||||
completed_run_response.error_message = None
|
||||
completed_run_response.insert_status = "failed"
|
||||
completed_run_response.superset_execution_id = ""
|
||||
@@ -429,9 +428,8 @@ class TestTranslationOrchestrator:
|
||||
with patch.object(
|
||||
orch, "_generate_and_insert_sql",
|
||||
return_value={"status": "failed", "error_message": "timeout", "query_id": None},
|
||||
):
|
||||
with patch.object(orch, "event_log"):
|
||||
result = orch.execute_run(run)
|
||||
), patch.object(orch, "event_log"):
|
||||
result = orch.execute_run(run)
|
||||
|
||||
assert result.status == "COMPLETED"
|
||||
assert result.insert_status == "failed"
|
||||
@@ -504,8 +502,8 @@ class TestTranslationOrchestrator:
|
||||
run.id = "run-1"
|
||||
run.job_id = "job-123"
|
||||
run.status = "COMPLETED"
|
||||
run.started_at = datetime.now(timezone.utc)
|
||||
run.completed_at = datetime.now(timezone.utc)
|
||||
run.started_at = datetime.now(UTC)
|
||||
run.completed_at = datetime.now(UTC)
|
||||
run.error_message = None
|
||||
run.total_records = 100
|
||||
run.successful_records = 95
|
||||
@@ -514,7 +512,7 @@ class TestTranslationOrchestrator:
|
||||
run.insert_status = "success"
|
||||
run.superset_execution_id = "query-1"
|
||||
run.created_by = "test-user"
|
||||
run.created_at = datetime.now(timezone.utc)
|
||||
run.created_at = datetime.now(UTC)
|
||||
|
||||
db.query.return_value.filter.return_value.first.return_value = run
|
||||
db.query.return_value.filter.return_value.count.return_value = 5
|
||||
@@ -545,7 +543,7 @@ class TestTranslationOrchestrator:
|
||||
record.source_object_name = ""
|
||||
record.status = "SUCCESS"
|
||||
record.error_message = None
|
||||
record.created_at = datetime.now(timezone.utc)
|
||||
record.created_at = datetime.now(UTC)
|
||||
|
||||
db.query.return_value.filter.return_value.count.return_value = 1
|
||||
db.query.return_value.filter.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [record]
|
||||
@@ -567,8 +565,8 @@ class TestTranslationOrchestrator:
|
||||
run.id = "run-1"
|
||||
run.job_id = "job-123"
|
||||
run.status = "COMPLETED"
|
||||
run.started_at = datetime.now(timezone.utc)
|
||||
run.completed_at = datetime.now(timezone.utc)
|
||||
run.started_at = datetime.now(UTC)
|
||||
run.completed_at = datetime.now(UTC)
|
||||
run.error_message = None
|
||||
run.total_records = 100
|
||||
run.successful_records = 95
|
||||
@@ -577,7 +575,7 @@ class TestTranslationOrchestrator:
|
||||
run.insert_status = "success"
|
||||
run.superset_execution_id = "query-1"
|
||||
run.created_by = "test-user"
|
||||
run.created_at = datetime.now(timezone.utc)
|
||||
run.created_at = datetime.now(UTC)
|
||||
|
||||
db.query.return_value.filter.return_value.count.return_value = 1
|
||||
db.query.return_value.filter.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [run]
|
||||
|
||||
@@ -4,18 +4,18 @@
|
||||
# @LAYER: Test
|
||||
# @RELATION: BINDS_TO -> [TranslationPreview:Module]
|
||||
|
||||
from unittest.mock import MagicMock, patch, PropertyMock
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.models.translate import (
|
||||
TranslationJob,
|
||||
TranslationPreviewSession,
|
||||
TranslationPreviewRecord,
|
||||
TranslationJobDictionary,
|
||||
TranslationPreviewSession,
|
||||
)
|
||||
from src.plugins.translate.preview import TranslationPreview, TokenEstimator
|
||||
from src.plugins.translate.preview import TokenEstimator, TranslationPreview
|
||||
|
||||
|
||||
# region _make_mock_job [TYPE Function]
|
||||
@@ -234,7 +234,7 @@ class TestTranslationPreview:
|
||||
session.id = "session-1"
|
||||
session.job_id = "job-123"
|
||||
session.status = "ACTIVE"
|
||||
session.created_at = datetime.now(timezone.utc)
|
||||
session.created_at = datetime.now(UTC)
|
||||
|
||||
# Mock record
|
||||
record = MagicMock(spec=TranslationPreviewRecord)
|
||||
@@ -371,8 +371,8 @@ class TestTranslationPreview:
|
||||
session.job_id = "job-123"
|
||||
session.status = "ACTIVE"
|
||||
session.created_by = "test-user"
|
||||
session.created_at = datetime.now(timezone.utc)
|
||||
session.expires_at = datetime.now(timezone.utc) + timedelta(hours=24)
|
||||
session.created_at = datetime.now(UTC)
|
||||
session.expires_at = datetime.now(UTC) + timedelta(hours=24)
|
||||
|
||||
record = MagicMock(spec=TranslationPreviewRecord)
|
||||
record.id = "record-1"
|
||||
|
||||
@@ -9,13 +9,14 @@
|
||||
# @TEST_EDGE: null_values -> properly encoded as NULL
|
||||
# @TEST_EDGE: sql_injection -> values with single quotes are escaped
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from src.plugins.translate.sql_generator import (
|
||||
SQLGenerator,
|
||||
_quote_identifier,
|
||||
_encode_sql_value,
|
||||
_quote_identifier,
|
||||
generate_insert_sql,
|
||||
generate_upsert_sql,
|
||||
)
|
||||
@@ -33,7 +34,7 @@ def _normalize_sql(sql: str) -> str:
|
||||
# region sample_rows [TYPE Function]
|
||||
# @PURPOSE: Sample rows fixture for SQL generation tests.
|
||||
@pytest.fixture
|
||||
def sample_rows() -> List[Dict[str, Any]]:
|
||||
def sample_rows() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{"col1": "hello", "col2": 42, "col3": None},
|
||||
{"col1": "world", "col2": 99, "col3": "note"},
|
||||
@@ -129,7 +130,7 @@ class TestEncodeSqlValue:
|
||||
class TestGenerateInsertSql:
|
||||
# region test_basic_insert [TYPE Function]
|
||||
# @PURPOSE: Generates basic INSERT with VALUES.
|
||||
def test_basic_insert(self, sample_rows: List[Dict[str, Any]]) -> None:
|
||||
def test_basic_insert(self, sample_rows: list[dict[str, Any]]) -> None:
|
||||
sql = generate_insert_sql(
|
||||
target_schema=None,
|
||||
target_table="my_table",
|
||||
@@ -145,7 +146,7 @@ class TestGenerateInsertSql:
|
||||
|
||||
# region test_insert_with_schema [TYPE Function]
|
||||
# @PURPOSE: Schema-qualified table name.
|
||||
def test_insert_with_schema(self, sample_rows: List[Dict[str, Any]]) -> None:
|
||||
def test_insert_with_schema(self, sample_rows: list[dict[str, Any]]) -> None:
|
||||
sql = generate_insert_sql(
|
||||
target_schema="public",
|
||||
target_table="my_table",
|
||||
@@ -157,7 +158,7 @@ class TestGenerateInsertSql:
|
||||
|
||||
# region test_empty_columns_raises [TYPE Function]
|
||||
# @PURPOSE: Empty columns list raises ValueError.
|
||||
def test_empty_columns_raises(self, sample_rows: List[Dict[str, Any]]) -> None:
|
||||
def test_empty_columns_raises(self, sample_rows: list[dict[str, Any]]) -> None:
|
||||
with pytest.raises(ValueError, match="At least one column"):
|
||||
generate_insert_sql(
|
||||
target_schema=None,
|
||||
@@ -218,7 +219,7 @@ class TestGenerateInsertSql:
|
||||
class TestGenerateUpsertSql:
|
||||
# region test_basic_upsert [TYPE Function]
|
||||
# @PURPOSE: Generates INSERT ... ON CONFLICT DO UPDATE.
|
||||
def test_basic_upsert(self, sample_rows: List[Dict[str, Any]]) -> None:
|
||||
def test_basic_upsert(self, sample_rows: list[dict[str, Any]]) -> None:
|
||||
sql = generate_upsert_sql(
|
||||
target_schema=None,
|
||||
target_table="my_table",
|
||||
@@ -247,7 +248,7 @@ class TestGenerateUpsertSql:
|
||||
|
||||
# region test_upsert_empty_keys [TYPE Function]
|
||||
# @PURPOSE: Empty key_columns raises ValueError.
|
||||
def test_upsert_empty_keys(self, sample_rows: List[Dict[str, Any]]) -> None:
|
||||
def test_upsert_empty_keys(self, sample_rows: list[dict[str, Any]]) -> None:
|
||||
with pytest.raises(ValueError, match="key_columns"):
|
||||
generate_upsert_sql(
|
||||
target_schema=None,
|
||||
@@ -268,7 +269,7 @@ class TestGenerateUpsertSql:
|
||||
class TestSQLGenerator:
|
||||
# region test_postgresql_insert [TYPE Function]
|
||||
# @PURPOSE: PostgreSQL dialect generates proper INSERT.
|
||||
def test_postgresql_insert(self, sample_rows: List[Dict[str, Any]]) -> None:
|
||||
def test_postgresql_insert(self, sample_rows: list[dict[str, Any]]) -> None:
|
||||
sql, count = SQLGenerator.generate(
|
||||
dialect="postgresql",
|
||||
target_schema="public",
|
||||
@@ -283,7 +284,7 @@ class TestSQLGenerator:
|
||||
|
||||
# region test_postgresql_upsert [TYPE Function]
|
||||
# @PURPOSE: PostgreSQL dialect generates proper UPSERT.
|
||||
def test_postgresql_upsert(self, sample_rows: List[Dict[str, Any]]) -> None:
|
||||
def test_postgresql_upsert(self, sample_rows: list[dict[str, Any]]) -> None:
|
||||
sql, count = SQLGenerator.generate(
|
||||
dialect="postgresql",
|
||||
target_schema="public",
|
||||
@@ -298,7 +299,7 @@ class TestSQLGenerator:
|
||||
|
||||
# region test_clickhouse_insert [TYPE Function]
|
||||
# @PURPOSE: ClickHouse dialect generates plain INSERT.
|
||||
def test_clickhouse_insert(self, sample_rows: List[Dict[str, Any]]) -> None:
|
||||
def test_clickhouse_insert(self, sample_rows: list[dict[str, Any]]) -> None:
|
||||
sql, count = SQLGenerator.generate(
|
||||
dialect="clickhouse",
|
||||
target_schema="default",
|
||||
@@ -314,7 +315,7 @@ class TestSQLGenerator:
|
||||
|
||||
# region test_clickhouse_upsert_fallback [TYPE Function]
|
||||
# @PURPOSE: ClickHouse with MERGE strategy falls back to plain INSERT with warning.
|
||||
def test_clickhouse_upsert_fallback(self, sample_rows: List[Dict[str, Any]]) -> None:
|
||||
def test_clickhouse_upsert_fallback(self, sample_rows: list[dict[str, Any]]) -> None:
|
||||
sql, count = SQLGenerator.generate(
|
||||
dialect="clickhouse",
|
||||
target_schema="default",
|
||||
@@ -342,7 +343,7 @@ class TestSQLGenerator:
|
||||
|
||||
# region test_generate_batch [TYPE Function]
|
||||
# @PURPOSE: generate_batch splits large row sets.
|
||||
def test_generate_batch(self, sample_rows: List[Dict[str, Any]]) -> None:
|
||||
def test_generate_batch(self, sample_rows: list[dict[str, Any]]) -> None:
|
||||
# Create many rows to test batching
|
||||
many_rows = sample_rows * 5 # 10 rows
|
||||
statements = SQLGenerator.generate_batch(
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
# @BRIEF Utility helpers for dictionary management (term normalization and delimiter detection).
|
||||
# @LAYER: Domain
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
|
||||
|
||||
@@ -12,17 +12,19 @@
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func
|
||||
from ...core.logger import logger, belief_scope
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...models.translate import (
|
||||
TerminologyDictionary,
|
||||
DictionaryEntry,
|
||||
TranslationJobDictionary,
|
||||
TerminologyDictionary,
|
||||
TranslationJob,
|
||||
TranslationJobDictionary,
|
||||
)
|
||||
from ._utils import _normalize_term, _detect_delimiter
|
||||
from ._utils import _detect_delimiter, _normalize_term
|
||||
|
||||
|
||||
# #region DictionaryManager [C:4] [TYPE Class]
|
||||
@@ -38,7 +40,7 @@ class DictionaryManager:
|
||||
@staticmethod
|
||||
def create_dictionary(
|
||||
db: Session, name: str, source_dialect: str, target_dialect: str,
|
||||
created_by: Optional[str] = None, description: Optional[str] = None,
|
||||
created_by: str | None = None, description: str | None = None,
|
||||
is_active: bool = True,
|
||||
) -> TerminologyDictionary:
|
||||
with belief_scope("DictionaryManager.create_dictionary"):
|
||||
@@ -64,9 +66,9 @@ class DictionaryManager:
|
||||
# @POST: Dictionary metadata is updated and returned.
|
||||
@staticmethod
|
||||
def update_dictionary(
|
||||
db: Session, dict_id: str, name: Optional[str] = None,
|
||||
description: Optional[str] = None, source_dialect: Optional[str] = None,
|
||||
target_dialect: Optional[str] = None, is_active: Optional[bool] = None,
|
||||
db: Session, dict_id: str, name: str | None = None,
|
||||
description: str | None = None, source_dialect: str | None = None,
|
||||
target_dialect: str | None = None, is_active: bool | None = None,
|
||||
) -> TerminologyDictionary:
|
||||
with belief_scope("DictionaryManager.update_dictionary"):
|
||||
dictionary = db.query(TerminologyDictionary).filter(TerminologyDictionary.id == dict_id).first()
|
||||
@@ -151,7 +153,7 @@ class DictionaryManager:
|
||||
@staticmethod
|
||||
def list_dictionaries(
|
||||
db: Session, page: int = 1, page_size: int = 20,
|
||||
) -> Tuple[List[TerminologyDictionary], int]:
|
||||
) -> tuple[list[TerminologyDictionary], int]:
|
||||
total = db.query(func.count(TerminologyDictionary.id)).scalar() or 0
|
||||
dictionaries = (
|
||||
db.query(TerminologyDictionary)
|
||||
@@ -170,7 +172,7 @@ class DictionaryManager:
|
||||
@staticmethod
|
||||
def add_entry(
|
||||
db: Session, dict_id: str, source_term: str, target_term: str,
|
||||
context_notes: Optional[str] = None,
|
||||
context_notes: str | None = None,
|
||||
) -> DictionaryEntry:
|
||||
with belief_scope("DictionaryManager.add_entry"):
|
||||
normalized = _normalize_term(source_term)
|
||||
@@ -209,8 +211,8 @@ class DictionaryManager:
|
||||
# @POST: Entry fields are updated.
|
||||
@staticmethod
|
||||
def edit_entry(
|
||||
db: Session, entry_id: str, source_term: Optional[str] = None,
|
||||
target_term: Optional[str] = None, context_notes: Optional[str] = None,
|
||||
db: Session, entry_id: str, source_term: str | None = None,
|
||||
target_term: str | None = None, context_notes: str | None = None,
|
||||
) -> DictionaryEntry:
|
||||
with belief_scope("DictionaryManager.edit_entry"):
|
||||
entry = db.query(DictionaryEntry).filter(DictionaryEntry.id == entry_id).first()
|
||||
@@ -287,7 +289,7 @@ class DictionaryManager:
|
||||
@staticmethod
|
||||
def list_entries(
|
||||
db: Session, dict_id: str, page: int = 1, page_size: int = 50,
|
||||
) -> Tuple[List[DictionaryEntry], int]:
|
||||
) -> tuple[list[DictionaryEntry], int]:
|
||||
total = (
|
||||
db.query(func.count(DictionaryEntry.id))
|
||||
.filter(DictionaryEntry.dictionary_id == dict_id)
|
||||
@@ -313,10 +315,10 @@ class DictionaryManager:
|
||||
@staticmethod
|
||||
def import_entries(
|
||||
db: Session, dict_id: str, content: str,
|
||||
delimiter: Optional[str] = None,
|
||||
delimiter: str | None = None,
|
||||
on_conflict: str = "overwrite",
|
||||
preview_only: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
with belief_scope("DictionaryManager.import_entries"):
|
||||
# Validate dictionary
|
||||
dictionary = db.query(TerminologyDictionary).filter(TerminologyDictionary.id == dict_id).first()
|
||||
@@ -329,7 +331,7 @@ class DictionaryManager:
|
||||
logger.reason("Detected delimiter", {"delimiter": repr(delimiter)})
|
||||
|
||||
if delimiter not in (",", "\t"):
|
||||
raise ValueError(f"Unsupported delimiter: {repr(delimiter)}. Use ',' or '\\t'.")
|
||||
raise ValueError(f"Unsupported delimiter: {delimiter!r}. Use ',' or '\\t'.")
|
||||
|
||||
# Parse content
|
||||
reader = csv.DictReader(io.StringIO(content), delimiter=delimiter)
|
||||
@@ -340,7 +342,7 @@ class DictionaryManager:
|
||||
f"Got: {reader.fieldnames}"
|
||||
)
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
result: dict[str, Any] = {
|
||||
"total": 0,
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
@@ -451,8 +453,8 @@ class DictionaryManager:
|
||||
# @SIDE_EFFECT: Queries TranslationJobDictionary, TerminologyDictionary, and DictionaryEntry tables.
|
||||
@staticmethod
|
||||
def filter_for_batch(
|
||||
db: Session, source_texts: List[str], job_id: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
db: Session, source_texts: list[str], job_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
with belief_scope("DictionaryManager.filter_for_batch"):
|
||||
# Get dictionaries attached to this job
|
||||
job_dict_links = (
|
||||
@@ -492,7 +494,7 @@ class DictionaryManager:
|
||||
if not all_entries:
|
||||
return []
|
||||
|
||||
matched: List[Dict[str, Any]] = []
|
||||
matched: list[dict[str, Any]] = []
|
||||
seen_source_match: set = set()
|
||||
|
||||
for text_idx, source_text in enumerate(source_texts):
|
||||
@@ -554,11 +556,11 @@ class DictionaryManager:
|
||||
source_term: str,
|
||||
incorrect_target_term: str,
|
||||
corrected_target_term: str,
|
||||
origin_run_id: Optional[str] = None,
|
||||
origin_row_key: Optional[str] = None,
|
||||
origin_user_id: Optional[str] = None,
|
||||
origin_run_id: str | None = None,
|
||||
origin_row_key: str | None = None,
|
||||
origin_user_id: str | None = None,
|
||||
on_conflict: str = "overwrite",
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
with belief_scope("DictionaryManager.submit_correction"):
|
||||
# Validate dictionary exists
|
||||
dictionary = db.query(TerminologyDictionary).filter(TerminologyDictionary.id == dict_id).first()
|
||||
@@ -575,7 +577,7 @@ class DictionaryManager:
|
||||
.first()
|
||||
)
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
result: dict[str, Any] = {
|
||||
"source_term": source_term,
|
||||
"target_term": corrected_target_term,
|
||||
"action": "created",
|
||||
@@ -648,17 +650,17 @@ class DictionaryManager:
|
||||
def submit_bulk_corrections(
|
||||
db: Session,
|
||||
dict_id: str,
|
||||
corrections: List[Dict[str, Any]],
|
||||
origin_user_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
corrections: list[dict[str, Any]],
|
||||
origin_user_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
with belief_scope("DictionaryManager.submit_bulk_corrections"):
|
||||
# Validate dictionary first
|
||||
dictionary = db.query(TerminologyDictionary).filter(TerminologyDictionary.id == dict_id).first()
|
||||
if not dictionary:
|
||||
raise ValueError(f"Dictionary '{dict_id}' not found")
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
conflicts: List[Dict[str, Any]] = []
|
||||
results: list[dict[str, Any]] = []
|
||||
conflicts: list[dict[str, Any]] = []
|
||||
all_ok = True
|
||||
|
||||
for corr in corrections:
|
||||
|
||||
@@ -11,13 +11,14 @@
|
||||
# @RATIONALE: Immutable event log with nullable run_id allows job-level events (no run context) and run-level events.
|
||||
# @REJECTED: stdout-only logging — lacks structured payload integrity; cannot enforce terminal-event invariant.
|
||||
# @REJECTED: Separate event table per entity — single TranslationEvent table with nullable run_id is simpler for audit.
|
||||
from typing import Any, Dict, List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...models.translate import TranslationEvent, MetricSnapshot
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...models.translate import MetricSnapshot, TranslationEvent
|
||||
|
||||
# Terminal events per run — exactly one of these must exist for a completed run.
|
||||
TERMINAL_EVENT_TYPES = {"RUN_COMPLETED", "RUN_FAILED", "RUN_CANCELLED"}
|
||||
@@ -56,9 +57,9 @@ class TranslationEventLog:
|
||||
self,
|
||||
job_id: str,
|
||||
event_type: str,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
run_id: Optional[str] = None,
|
||||
created_by: Optional[str] = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
run_id: str | None = None,
|
||||
created_by: str | None = None,
|
||||
) -> TranslationEvent:
|
||||
with belief_scope("TranslationEventLog.log_event"):
|
||||
if event_type not in VALID_EVENT_TYPES:
|
||||
@@ -106,7 +107,7 @@ class TranslationEventLog:
|
||||
event_type=event_type,
|
||||
event_data=payload or {},
|
||||
created_by=created_by,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
self.db.add(event)
|
||||
self.db.flush()
|
||||
@@ -126,12 +127,12 @@ class TranslationEventLog:
|
||||
# @POST: Returns list of TranslationEvent dicts matching filters.
|
||||
def query_events(
|
||||
self,
|
||||
job_id: Optional[str] = None,
|
||||
run_id: Optional[str] = None,
|
||||
event_type: Optional[str] = None,
|
||||
job_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
event_type: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> List[Dict[str, Any]]:
|
||||
) -> list[dict[str, Any]]:
|
||||
with belief_scope("TranslationEventLog.query_events"):
|
||||
query = self.db.query(TranslationEvent)
|
||||
|
||||
@@ -172,9 +173,9 @@ class TranslationEventLog:
|
||||
self,
|
||||
retention_days: int = DEFAULT_RETENTION_DAYS,
|
||||
batch_size: int = 1000,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
with belief_scope("TranslationEventLog.prune_expired"):
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
|
||||
cutoff = datetime.now(UTC) - timedelta(days=retention_days)
|
||||
logger.reason("Pruning expired events", {
|
||||
"cutoff": cutoff.isoformat(),
|
||||
"retention_days": retention_days,
|
||||
@@ -197,7 +198,7 @@ class TranslationEventLog:
|
||||
key_hash=f"prune_{cutoff.timestamp():.0f}",
|
||||
covers_events_before=cutoff,
|
||||
total_records=total_expired,
|
||||
snapshot_date=datetime.now(timezone.utc),
|
||||
snapshot_date=datetime.now(UTC),
|
||||
)
|
||||
self.db.add(snapshot)
|
||||
self.db.flush()
|
||||
@@ -235,7 +236,7 @@ class TranslationEventLog:
|
||||
# @PURPOSE: Get a summary of events for a run, including invariant check.
|
||||
# @PRE: run_id is not None.
|
||||
# @POST: Returns dict with event list and invariant validity.
|
||||
def get_run_event_summary(self, run_id: str) -> Dict[str, Any]:
|
||||
def get_run_event_summary(self, run_id: str) -> dict[str, Any]:
|
||||
with belief_scope("TranslationEventLog.get_run_event_summary"):
|
||||
events = self.query_events(run_id=run_id)
|
||||
|
||||
|
||||
@@ -16,27 +16,27 @@
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple, Callable
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...models.translate import (
|
||||
TranslationJob,
|
||||
TranslationRun,
|
||||
TranslationBatch,
|
||||
TranslationRecord,
|
||||
TranslationPreviewSession,
|
||||
TranslationJob,
|
||||
TranslationPreviewRecord,
|
||||
TranslationPreviewSession,
|
||||
TranslationRecord,
|
||||
TranslationRun,
|
||||
)
|
||||
from ...services.llm_provider import LLMProviderService
|
||||
from ...services.llm_prompt_templates import render_prompt
|
||||
from ...services.llm_provider import LLMProviderService
|
||||
from .dictionary import DictionaryManager
|
||||
from .preview import DEFAULT_EXECUTION_PROMPT_TEMPLATE
|
||||
|
||||
|
||||
# #region MAX_RETRIES_PER_BATCH [TYPE Constant]
|
||||
# @BRIEF Maximum number of retries for a single batch before marking it failed.
|
||||
MAX_RETRIES_PER_BATCH = 3
|
||||
@@ -54,14 +54,14 @@ class TranslationExecutor:
|
||||
self,
|
||||
db: Session,
|
||||
config_manager: ConfigManager,
|
||||
current_user: Optional[str] = None,
|
||||
on_batch_progress: Optional[Callable[[str, int, int, int, int], None]] = None,
|
||||
current_user: str | None = None,
|
||||
on_batch_progress: Callable[[str, int, int, int, int], None] | None = None,
|
||||
):
|
||||
self.db = db
|
||||
self.config_manager = config_manager
|
||||
self.current_user = current_user
|
||||
self.on_batch_progress = on_batch_progress
|
||||
self._current_run_id: Optional[str] = None
|
||||
self._current_run_id: str | None = None
|
||||
|
||||
# region execute_run [TYPE Function]
|
||||
# @PURPOSE: Run full translation execution for a TranslationRun.
|
||||
@@ -71,7 +71,7 @@ class TranslationExecutor:
|
||||
def execute_run(
|
||||
self,
|
||||
run: TranslationRun,
|
||||
llm_progress_callback: Optional[Callable[[str, int, int, int], None]] = None,
|
||||
llm_progress_callback: Callable[[str, int, int, int], None] | None = None,
|
||||
) -> TranslationRun:
|
||||
with belief_scope("TranslationExecutor.execute_run"):
|
||||
job = self.db.query(TranslationJob).filter(TranslationJob.id == run.job_id).first()
|
||||
@@ -86,7 +86,7 @@ class TranslationExecutor:
|
||||
|
||||
# Mark run as RUNNING
|
||||
run.status = "RUNNING"
|
||||
run.started_at = datetime.now(timezone.utc)
|
||||
run.started_at = datetime.now(UTC)
|
||||
self.db.flush()
|
||||
|
||||
# Fetch source rows from the accepted preview session
|
||||
@@ -94,7 +94,7 @@ class TranslationExecutor:
|
||||
if not source_rows:
|
||||
logger.explore("No source rows to translate", {"run_id": run.id})
|
||||
run.status = "COMPLETED"
|
||||
run.completed_at = datetime.now(timezone.utc)
|
||||
run.completed_at = datetime.now(UTC)
|
||||
self.db.flush()
|
||||
return run
|
||||
|
||||
@@ -108,7 +108,7 @@ class TranslationExecutor:
|
||||
run.status = "COMPLETED"
|
||||
run.insert_status = "skipped"
|
||||
run.total_records = 0
|
||||
run.completed_at = datetime.now(timezone.utc)
|
||||
run.completed_at = datetime.now(UTC)
|
||||
self.db.commit()
|
||||
return
|
||||
|
||||
@@ -163,7 +163,7 @@ class TranslationExecutor:
|
||||
else:
|
||||
run.status = "COMPLETED" # Partial success
|
||||
|
||||
run.completed_at = datetime.now(timezone.utc)
|
||||
run.completed_at = datetime.now(UTC)
|
||||
self.db.flush()
|
||||
|
||||
logger.reflect("Translation execution complete", {
|
||||
@@ -183,7 +183,7 @@ class TranslationExecutor:
|
||||
# @PRE: job_id exists. Job may have source_datasource_id for full fetch.
|
||||
# @POST: Returns list of dicts with source data (all rows from the source datasource).
|
||||
# @SIDE_EFFECT: Makes HTTP call to Superset chart data API when datasource is configured.
|
||||
def _fetch_source_rows(self, job_id: str, run_id: str) -> List[Dict[str, Any]]:
|
||||
def _fetch_source_rows(self, job_id: str, run_id: str) -> list[dict[str, Any]]:
|
||||
with belief_scope("TranslationExecutor._fetch_source_rows"):
|
||||
job = self.db.query(TranslationJob).filter(TranslationJob.id == job_id).first()
|
||||
|
||||
@@ -390,7 +390,7 @@ class TranslationExecutor:
|
||||
# @PURPOSE: Extract data rows from Superset chart data API response.
|
||||
# @POST: Returns list of dicts with column-value pairs.
|
||||
@staticmethod
|
||||
def _extract_chart_data_rows(response: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
def _extract_chart_data_rows(response: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
result = response.get("result")
|
||||
if isinstance(result, list):
|
||||
for item in result:
|
||||
@@ -420,8 +420,8 @@ class TranslationExecutor:
|
||||
job: TranslationJob,
|
||||
run_id: str,
|
||||
batch_index: int,
|
||||
batch_rows: List[Dict[str, Any]],
|
||||
) -> Dict[str, int]:
|
||||
batch_rows: list[dict[str, Any]],
|
||||
) -> dict[str, int]:
|
||||
with belief_scope("TranslationExecutor._process_batch"):
|
||||
batch_start = time.monotonic()
|
||||
|
||||
@@ -432,7 +432,7 @@ class TranslationExecutor:
|
||||
batch_index=batch_index,
|
||||
status="RUNNING",
|
||||
total_records=len(batch_rows),
|
||||
started_at=datetime.now(timezone.utc),
|
||||
started_at=datetime.now(UTC),
|
||||
)
|
||||
self.db.add(batch)
|
||||
self.db.flush()
|
||||
@@ -496,7 +496,7 @@ class TranslationExecutor:
|
||||
# Update batch status
|
||||
batch.successful_records = result["successful"]
|
||||
batch.failed_records = result["failed"]
|
||||
batch.completed_at = datetime.now(timezone.utc)
|
||||
batch.completed_at = datetime.now(UTC)
|
||||
batch.status = "COMPLETED" if result["failed"] == 0 else "COMPLETED_WITH_ERRORS"
|
||||
self.db.flush()
|
||||
|
||||
@@ -519,10 +519,10 @@ class TranslationExecutor:
|
||||
self,
|
||||
job: TranslationJob,
|
||||
run_id: str,
|
||||
batch_rows: List[Dict[str, Any]],
|
||||
dict_matches: List[Dict[str, Any]],
|
||||
batch_rows: list[dict[str, Any]],
|
||||
dict_matches: list[dict[str, Any]],
|
||||
batch_id: str,
|
||||
) -> Dict[str, int]:
|
||||
) -> dict[str, int]:
|
||||
with belief_scope("TranslationExecutor._call_llm_for_batch"):
|
||||
# Build dictionary section
|
||||
dictionary_section = ""
|
||||
@@ -810,7 +810,7 @@ class TranslationExecutor:
|
||||
# @PRE: response_text is valid JSON with {"rows": [...]} structure.
|
||||
# @POST: Returns dict mapping row_id to translation text.
|
||||
@staticmethod
|
||||
def _parse_llm_response(response_text: str, expected_count: int) -> Dict[str, str]:
|
||||
def _parse_llm_response(response_text: str, expected_count: int) -> dict[str, str]:
|
||||
with belief_scope("TranslationExecutor._parse_llm_response"):
|
||||
try:
|
||||
data = json.loads(response_text)
|
||||
@@ -830,7 +830,7 @@ class TranslationExecutor:
|
||||
if not isinstance(rows, list):
|
||||
raise ValueError("LLM response missing 'rows' array")
|
||||
|
||||
translations: Dict[str, str] = {}
|
||||
translations: dict[str, str] = {}
|
||||
for item in rows:
|
||||
row_id = str(item.get("row_id", ""))
|
||||
translation = item.get("translation")
|
||||
|
||||
@@ -10,15 +10,16 @@
|
||||
# @RATIONALE: Live events (<90 days) + MetricSnapshot (>=90 days) fusion for complete picture.
|
||||
# @REJECTED: Querying all events for all time — would be slow; MetricSnapshot provides historical summary.
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from ...core.logger import logger, belief_scope
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.logger import belief_scope
|
||||
from ...models.translate import (
|
||||
TranslationEvent,
|
||||
MetricSnapshot,
|
||||
TranslationEvent,
|
||||
TranslationRun,
|
||||
TranslationSchedule,
|
||||
)
|
||||
@@ -35,7 +36,7 @@ class TranslationMetrics:
|
||||
# @PURPOSE: Get aggregated metrics for a specific job.
|
||||
# @PRE: job_id exists.
|
||||
# @POST: Returns dict with metrics from events + latest snapshot.
|
||||
def get_job_metrics(self, job_id: str) -> Dict[str, Any]:
|
||||
def get_job_metrics(self, job_id: str) -> dict[str, Any]:
|
||||
with belief_scope("TranslationMetrics.get_job_metrics"):
|
||||
# Run counts from TranslationRun
|
||||
run_counts = (
|
||||
@@ -49,7 +50,7 @@ class TranslationMetrics:
|
||||
)
|
||||
|
||||
total_runs = 0
|
||||
status_counts: Dict[str, int] = {}
|
||||
status_counts: dict[str, int] = {}
|
||||
for status_val, count in run_counts:
|
||||
status_counts[status_val] = count
|
||||
total_runs += count
|
||||
@@ -121,7 +122,7 @@ class TranslationMetrics:
|
||||
pass
|
||||
|
||||
# Live events (<90 days) for token/cost
|
||||
cutoff = datetime.now(timezone.utc)
|
||||
cutoff = datetime.now(UTC)
|
||||
live_events = (
|
||||
self.db.query(TranslationEvent)
|
||||
.filter(
|
||||
@@ -153,7 +154,7 @@ class TranslationMetrics:
|
||||
# region get_all_metrics [TYPE Function]
|
||||
# @PURPOSE: Get aggregated metrics for all jobs.
|
||||
# @POST: Returns list of per-job metrics.
|
||||
def get_all_metrics(self) -> List[Dict[str, Any]]:
|
||||
def get_all_metrics(self) -> list[dict[str, Any]]:
|
||||
with belief_scope("TranslationMetrics.get_all_metrics"):
|
||||
job_ids = (
|
||||
self.db.query(TranslationRun.job_id)
|
||||
|
||||
@@ -18,28 +18,26 @@
|
||||
# @REJECTED: stdout-only logging — lacks structured payload integrity; cannot enforce terminal-event invariant.
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Callable, Tuple
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...models.translate import (
|
||||
TranslationJob,
|
||||
TranslationRun,
|
||||
TranslationBatch,
|
||||
TranslationRecord,
|
||||
TranslationJob,
|
||||
TranslationPreviewSession,
|
||||
TranslationRecord,
|
||||
TranslationRun,
|
||||
)
|
||||
from ...schemas.translate import TranslationRunResponse
|
||||
from .events import TranslationEventLog
|
||||
from .executor import TranslationExecutor
|
||||
from .sql_generator import SQLGenerator
|
||||
from .superset_executor import SupersetSqlLabExecutor
|
||||
from .events import TranslationEventLog
|
||||
from ..translate.service import TranslateJobService
|
||||
|
||||
|
||||
# #region TranslationOrchestrator [C:5] [TYPE Class]
|
||||
@@ -54,13 +52,13 @@ class TranslationOrchestrator:
|
||||
self,
|
||||
db: Session,
|
||||
config_manager: ConfigManager,
|
||||
current_user: Optional[str] = None,
|
||||
current_user: str | None = None,
|
||||
):
|
||||
self.db = db
|
||||
self.config_manager = config_manager
|
||||
self.current_user = current_user
|
||||
self.event_log = TranslationEventLog(db)
|
||||
self._job: Optional[TranslationJob] = None
|
||||
self._job: TranslationJob | None = None
|
||||
|
||||
# region start_run [TYPE Function]
|
||||
# @PURPOSE: Start a new translation run for a job.
|
||||
@@ -71,7 +69,7 @@ class TranslationOrchestrator:
|
||||
self,
|
||||
job_id: str,
|
||||
is_scheduled: bool = False,
|
||||
trigger_type: Optional[str] = None,
|
||||
trigger_type: str | None = None,
|
||||
) -> TranslationRun:
|
||||
with belief_scope("TranslationOrchestrator.start_run"):
|
||||
# Load and validate job
|
||||
@@ -133,7 +131,7 @@ class TranslationOrchestrator:
|
||||
config_hash=config_hash,
|
||||
dict_snapshot_hash=dict_snapshot_hash,
|
||||
created_by=self.current_user,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
self.db.add(run)
|
||||
self.db.flush()
|
||||
@@ -173,7 +171,7 @@ class TranslationOrchestrator:
|
||||
def execute_run(
|
||||
self,
|
||||
run: TranslationRun,
|
||||
on_batch_progress: Optional[Callable[[str, int, int, int, int], None]] = None,
|
||||
on_batch_progress: Callable[[str, int, int, int, int], None] | None = None,
|
||||
skip_insert: bool = False,
|
||||
) -> TranslationRun:
|
||||
with belief_scope("TranslationOrchestrator.execute_run"):
|
||||
@@ -217,7 +215,7 @@ class TranslationOrchestrator:
|
||||
})
|
||||
run.status = "FAILED"
|
||||
run.error_message = f"Translation execution failed: {e}"
|
||||
run.completed_at = datetime.now(timezone.utc)
|
||||
run.completed_at = datetime.now(UTC)
|
||||
self.db.flush()
|
||||
self.event_log.log_event(
|
||||
job_id=job.id,
|
||||
@@ -246,7 +244,7 @@ class TranslationOrchestrator:
|
||||
# Skip insert phase if requested (e.g., for preview-only execution)
|
||||
if skip_insert:
|
||||
run.status = "COMPLETED"
|
||||
run.completed_at = datetime.now(timezone.utc)
|
||||
run.completed_at = datetime.now(UTC)
|
||||
self.db.flush()
|
||||
self.event_log.log_event(
|
||||
job_id=job.id,
|
||||
@@ -270,7 +268,7 @@ class TranslationOrchestrator:
|
||||
run.status = "COMPLETED"
|
||||
if insert_result.get("error_message"):
|
||||
run.error_message = insert_result["error_message"]
|
||||
run.completed_at = datetime.now(timezone.utc)
|
||||
run.completed_at = datetime.now(UTC)
|
||||
self.db.flush()
|
||||
|
||||
# Record terminal event
|
||||
@@ -313,7 +311,7 @@ class TranslationOrchestrator:
|
||||
self,
|
||||
job: TranslationJob,
|
||||
run: TranslationRun,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
with belief_scope("TranslationOrchestrator._generate_and_insert_sql"):
|
||||
# Fetch successful records
|
||||
records = (
|
||||
@@ -620,7 +618,7 @@ class TranslationOrchestrator:
|
||||
else:
|
||||
run.status = "COMPLETED"
|
||||
|
||||
run.completed_at = datetime.now(timezone.utc)
|
||||
run.completed_at = datetime.now(UTC)
|
||||
self.db.flush()
|
||||
|
||||
self.event_log.log_event(
|
||||
@@ -707,7 +705,7 @@ class TranslationOrchestrator:
|
||||
)
|
||||
|
||||
run.status = "CANCELLED"
|
||||
run.completed_at = datetime.now(timezone.utc)
|
||||
run.completed_at = datetime.now(UTC)
|
||||
self.db.flush()
|
||||
|
||||
self.event_log.log_event(
|
||||
@@ -731,7 +729,7 @@ class TranslationOrchestrator:
|
||||
# @PURPOSE: Get run status with statistics.
|
||||
# @PRE: run_id exists.
|
||||
# @POST: Returns dict with run details.
|
||||
def get_run_status(self, run_id: str) -> Dict[str, Any]:
|
||||
def get_run_status(self, run_id: str) -> dict[str, Any]:
|
||||
with belief_scope("TranslationOrchestrator.get_run_status"):
|
||||
run = self.db.query(TranslationRun).filter(TranslationRun.id == run_id).first()
|
||||
if not run:
|
||||
@@ -780,8 +778,8 @@ class TranslationOrchestrator:
|
||||
run_id: str,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
status_filter: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
status_filter: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
with belief_scope("TranslationOrchestrator.get_run_records"):
|
||||
query = self.db.query(TranslationRecord).filter(
|
||||
TranslationRecord.run_id == run_id
|
||||
@@ -831,7 +829,7 @@ class TranslationOrchestrator:
|
||||
job_id: str,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> Tuple[int, List[Dict[str, Any]]]:
|
||||
) -> tuple[int, list[dict[str, Any]]]:
|
||||
with belief_scope("TranslationOrchestrator.get_run_history"):
|
||||
query = self.db.query(TranslationRun).filter(
|
||||
TranslationRun.job_id == job_id
|
||||
@@ -888,6 +886,7 @@ class TranslationOrchestrator:
|
||||
# @PURPOSE: Compute a hash of dictionary state for snapshot comparison.
|
||||
def _compute_dict_snapshot_hash(self, job_id: str) -> str:
|
||||
import hashlib
|
||||
|
||||
from ...models.translate import TranslationJobDictionary
|
||||
dict_links = (
|
||||
self.db.query(TranslationJobDictionary)
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
# @RATIONALE: Separate plugin avoids bloating LLMAnalysisPlugin beyond fractal limit (<400 lines).
|
||||
# @REJECTED: Extending LLMAnalysisPlugin would conflate two distinct feature domains.
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Any
|
||||
|
||||
from ...core.plugin_base import PluginBase
|
||||
|
||||
|
||||
@@ -29,7 +30,7 @@ class TranslatePlugin(PluginBase):
|
||||
def version(self) -> str:
|
||||
return "1.0.0"
|
||||
|
||||
def get_schema(self) -> Dict[str, Any]:
|
||||
def get_schema(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -52,7 +53,7 @@ class TranslatePlugin(PluginBase):
|
||||
"required": ["job_id"],
|
||||
}
|
||||
|
||||
async def execute(self, params: Dict[str, Any], context: Optional[Any] = None):
|
||||
async def execute(self, params: dict[str, Any], context: Any | None = None):
|
||||
"""Execute a translation job — implementation deferred to later phases."""
|
||||
raise NotImplementedError("TranslatePlugin.execute not yet implemented")
|
||||
# #endregion TranslatePlugin
|
||||
|
||||
@@ -15,25 +15,25 @@
|
||||
# @RATIONALE: C4 because preview is stateful with approve/edit/reject lifecycle and LLM API calls.
|
||||
# @REJECTED: Transient preview state (in-memory/session-scoped) — would lose decisions on restart and cannot gate execution reliably.
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import uuid
|
||||
import json
|
||||
import hashlib
|
||||
import re
|
||||
import json
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...core.superset_client import SupersetClient
|
||||
from ...models.translate import (
|
||||
TranslationJob,
|
||||
TranslationPreviewSession,
|
||||
TranslationPreviewRecord,
|
||||
TranslationJobDictionary,
|
||||
TranslationPreviewRecord,
|
||||
TranslationPreviewSession,
|
||||
)
|
||||
from ...services.llm_provider import LLMProviderService
|
||||
from ...services.llm_prompt_templates import render_prompt
|
||||
from ...services.llm_provider import LLMProviderService
|
||||
from .dictionary import DictionaryManager
|
||||
|
||||
# #region DEFAULT_EXECUTION_PROMPT_TEMPLATE [TYPE Constant]
|
||||
@@ -107,7 +107,7 @@ class TokenEstimator:
|
||||
# @PRE: total_tokens >= 0.
|
||||
# @POST: Returns estimated cost in USD.
|
||||
@staticmethod
|
||||
def estimate_cost(total_tokens: int, cost_per_1k: Optional[float] = None) -> float:
|
||||
def estimate_cost(total_tokens: int, cost_per_1k: float | None = None) -> float:
|
||||
rate = cost_per_1k if cost_per_1k is not None else TokenEstimator.TOKEN_COST_PER_1K
|
||||
return round((total_tokens / 1000) * rate, 6)
|
||||
# endregion estimate_cost
|
||||
@@ -127,7 +127,7 @@ class TranslationPreview:
|
||||
self,
|
||||
db: Session,
|
||||
config_manager: ConfigManager,
|
||||
current_user: Optional[str] = None,
|
||||
current_user: str | None = None,
|
||||
):
|
||||
self.db = db
|
||||
self.config_manager = config_manager
|
||||
@@ -142,9 +142,9 @@ class TranslationPreview:
|
||||
self,
|
||||
job_id: str,
|
||||
sample_size: int = 10,
|
||||
prompt_template: Optional[str] = None,
|
||||
env_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
prompt_template: str | None = None,
|
||||
env_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
with belief_scope("TranslationPreview.preview_rows"):
|
||||
logger.reason("Starting preview for job", {"job_id": job_id, "sample_size": sample_size})
|
||||
|
||||
@@ -189,7 +189,7 @@ class TranslationPreview:
|
||||
|
||||
# 4. Build prompt context from rows
|
||||
all_source_texts = []
|
||||
row_meta: List[Dict[str, Any]] = []
|
||||
row_meta: list[dict[str, Any]] = []
|
||||
for idx, row in enumerate(source_rows):
|
||||
translation_value = str(row.get(job.translation_column, "") or "")
|
||||
context_values = {}
|
||||
@@ -274,8 +274,8 @@ class TranslationPreview:
|
||||
job_id=job_id,
|
||||
status="ACTIVE",
|
||||
created_by=self.current_user,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(hours=24),
|
||||
created_at=datetime.now(UTC),
|
||||
expires_at=datetime.now(UTC) + timedelta(hours=24),
|
||||
)
|
||||
self.db.add(session)
|
||||
self.db.flush()
|
||||
@@ -313,7 +313,7 @@ class TranslationPreview:
|
||||
source_data=source_data,
|
||||
status=status,
|
||||
feedback=feedback,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
self.db.add(record)
|
||||
self.db.flush()
|
||||
@@ -369,9 +369,9 @@ class TranslationPreview:
|
||||
job_id: str,
|
||||
row_id: str,
|
||||
action: str,
|
||||
translation: Optional[str] = None,
|
||||
feedback: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
translation: str | None = None,
|
||||
feedback: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
with belief_scope("TranslationPreview.update_preview_row"):
|
||||
# Find the active session for this job
|
||||
session = (
|
||||
@@ -434,7 +434,7 @@ class TranslationPreview:
|
||||
# @PRE: job_id has an ACTIVE preview session.
|
||||
# @POST: Session status changes to APPLIED.
|
||||
# @SIDE_EFFECT: Future full execution calls will check for accepted session.
|
||||
def accept_preview_session(self, job_id: str) -> Dict[str, Any]:
|
||||
def accept_preview_session(self, job_id: str) -> dict[str, Any]:
|
||||
with belief_scope("TranslationPreview.accept_preview_session"):
|
||||
session = (
|
||||
self.db.query(TranslationPreviewSession)
|
||||
@@ -488,7 +488,7 @@ class TranslationPreview:
|
||||
# @PURPOSE: Get the latest preview session for a job with its records.
|
||||
# @PRE: job_id exists.
|
||||
# @POST: Returns session data with records or raises ValueError.
|
||||
def get_preview_session(self, job_id: str) -> Dict[str, Any]:
|
||||
def get_preview_session(self, job_id: str) -> dict[str, Any]:
|
||||
with belief_scope("TranslationPreview.get_preview_session"):
|
||||
session = (
|
||||
self.db.query(TranslationPreviewSession)
|
||||
@@ -533,7 +533,7 @@ class TranslationPreview:
|
||||
# @PRE: job has source_datasource_id and translation_column.
|
||||
# @POST: Returns list of dicts with row data.
|
||||
# @SIDE_EFFECT: Calls Superset chart data endpoint.
|
||||
def _fetch_sample_rows(self, job: TranslationJob, sample_size: int = 10, env_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
def _fetch_sample_rows(self, job: TranslationJob, sample_size: int = 10, env_id: str | None = None) -> list[dict[str, Any]]:
|
||||
with belief_scope("TranslationPreview._fetch_sample_rows"):
|
||||
# Determine environment: prefer explicit env_id, then job.environment_id, then job.source_dialect (legacy)
|
||||
environments = self.config_manager.get_environments()
|
||||
@@ -618,7 +618,7 @@ class TranslationPreview:
|
||||
# @PRE: response is a dict from Superset API.
|
||||
# @POST: Returns list of row dicts.
|
||||
@staticmethod
|
||||
def _extract_data_rows(response: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
def _extract_data_rows(response: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
with belief_scope("TranslationPreview._extract_data_rows"):
|
||||
# Try various response formats
|
||||
result = response.get("result")
|
||||
@@ -756,7 +756,7 @@ class TranslationPreview:
|
||||
# @PRE: response_text is valid JSON with {"rows": [...]} structure.
|
||||
# @POST: Returns dict mapping string row_id to translation text.
|
||||
@staticmethod
|
||||
def _parse_llm_response(response_text: str, expected_count: int) -> Dict[str, str]:
|
||||
def _parse_llm_response(response_text: str, expected_count: int) -> dict[str, str]:
|
||||
with belief_scope("TranslationPreview._parse_llm_response"):
|
||||
logger.reason(f"Raw LLM response length={len(response_text)} preview={response_text[:500]}")
|
||||
|
||||
@@ -779,7 +779,7 @@ class TranslationPreview:
|
||||
logger.explore(f"LLM response has no 'rows' array, keys={list(data.keys())} text_preview={response_text[:300]}")
|
||||
raise ValueError("LLM response missing 'rows' array")
|
||||
|
||||
translations: Dict[str, str] = {}
|
||||
translations: dict[str, str] = {}
|
||||
for item in rows:
|
||||
row_id = str(item.get("row_id", ""))
|
||||
translation = str(item.get("translation", ""))
|
||||
|
||||
@@ -13,15 +13,14 @@
|
||||
# @REJECTED: Polling-based approach — event-driven APScheduler is more precise.
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...core.cot_logger import seed_trace_id
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...models.translate import TranslationSchedule, TranslationJob, TranslationRun
|
||||
from ...core.cot_logger import seed_trace_id
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...models.translate import TranslationJob, TranslationRun, TranslationSchedule
|
||||
from .events import TranslationEventLog
|
||||
|
||||
|
||||
@@ -29,7 +28,7 @@ from .events import TranslationEventLog
|
||||
# @BRIEF CRUD for TranslationSchedule rows + APScheduler registration wrappers.
|
||||
class TranslationScheduler:
|
||||
|
||||
def __init__(self, db: Session, config_manager: ConfigManager, current_user: Optional[str] = None):
|
||||
def __init__(self, db: Session, config_manager: ConfigManager, current_user: str | None = None):
|
||||
self.db = db
|
||||
self.config_manager = config_manager
|
||||
self.current_user = current_user
|
||||
@@ -88,10 +87,10 @@ class TranslationScheduler:
|
||||
def update_schedule(
|
||||
self,
|
||||
job_id: str,
|
||||
cron_expression: Optional[str] = None,
|
||||
timezone_str: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
execution_mode: Optional[str] = None,
|
||||
cron_expression: str | None = None,
|
||||
timezone_str: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
execution_mode: str | None = None,
|
||||
) -> TranslationSchedule:
|
||||
with belief_scope("TranslationScheduler.update_schedule"):
|
||||
schedule = self.db.query(TranslationSchedule).filter(
|
||||
@@ -108,7 +107,7 @@ class TranslationScheduler:
|
||||
schedule.is_active = is_active
|
||||
if execution_mode is not None:
|
||||
schedule.execution_mode = execution_mode
|
||||
schedule.updated_at = datetime.now(timezone.utc)
|
||||
schedule.updated_at = datetime.now(UTC)
|
||||
self.db.commit()
|
||||
self.db.refresh(schedule)
|
||||
|
||||
@@ -167,7 +166,7 @@ class TranslationScheduler:
|
||||
raise ValueError(f"No schedule found for job '{job_id}'")
|
||||
|
||||
schedule.is_active = is_active
|
||||
schedule.updated_at = datetime.now(timezone.utc)
|
||||
schedule.updated_at = datetime.now(UTC)
|
||||
self.db.commit()
|
||||
self.db.refresh(schedule)
|
||||
|
||||
@@ -197,7 +196,7 @@ class TranslationScheduler:
|
||||
# @PURPOSE: List all active schedules.
|
||||
# @POST: Returns list of active TranslationSchedule rows.
|
||||
@staticmethod
|
||||
def list_active_schedules(db: Session) -> List[TranslationSchedule]:
|
||||
def list_active_schedules(db: Session) -> list[TranslationSchedule]:
|
||||
return (
|
||||
db.query(TranslationSchedule)
|
||||
.filter(TranslationSchedule.is_active == True)
|
||||
@@ -210,10 +209,10 @@ class TranslationScheduler:
|
||||
# @PRE: cron_expression is valid.
|
||||
# @POST: Returns list of ISO datetime strings.
|
||||
@staticmethod
|
||||
def get_next_executions(cron_expression: str, timezone_str: str = "UTC", n: int = 3) -> List[str]:
|
||||
def get_next_executions(cron_expression: str, timezone_str: str = "UTC", n: int = 3) -> list[str]:
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
try:
|
||||
tz = ZoneInfo(timezone_str)
|
||||
@@ -274,7 +273,7 @@ def execute_scheduled_translation(
|
||||
|
||||
# Concurrency check: max 1 pending/running run per job
|
||||
STALE_THRESHOLD = timedelta(hours=1)
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(UTC)
|
||||
|
||||
active_run = (
|
||||
db.query(TranslationRun)
|
||||
@@ -337,7 +336,7 @@ def execute_scheduled_translation(
|
||||
.first()
|
||||
)
|
||||
if most_recent:
|
||||
age = datetime.now(timezone.utc) - most_recent.created_at
|
||||
age = datetime.now(UTC) - most_recent.created_at
|
||||
if age > timedelta(days=90):
|
||||
baseline_expired = True
|
||||
logger.reason("Baseline expired — full translation", {
|
||||
@@ -388,10 +387,10 @@ def execute_scheduled_translation(
|
||||
# Leave schedule enabled — schedule continues on failure
|
||||
run.status = "FAILED"
|
||||
run.error_message = str(exec_err)
|
||||
run.completed_at = datetime.now(timezone.utc)
|
||||
run.completed_at = datetime.now(UTC)
|
||||
|
||||
# Update schedule tracking
|
||||
schedule.last_run_at = datetime.now(timezone.utc)
|
||||
schedule.last_run_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
|
||||
logger.reflect("Scheduled translation complete", {
|
||||
|
||||
@@ -10,21 +10,22 @@
|
||||
# @RATIONALE: Snapshot isolation — in-progress runs use config snapshot; config edits affect future runs only.
|
||||
# @REJECTED: Invalidating in-progress runs on config edit would break scheduled run continuity.
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.logger import logger
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...core.logger import logger
|
||||
from ...core.superset_client import SupersetClient
|
||||
from ...models.translate import TranslationJob, TranslationJobDictionary
|
||||
from ...schemas.translate import (
|
||||
TranslateJobCreate,
|
||||
TranslateJobUpdate,
|
||||
TranslateJobResponse,
|
||||
DatasourceColumnsResponse,
|
||||
DatasourceColumnResponse,
|
||||
DatasourceColumnsResponse,
|
||||
TranslateJobCreate,
|
||||
TranslateJobResponse,
|
||||
TranslateJobUpdate,
|
||||
)
|
||||
|
||||
# Supported database dialects for translation
|
||||
@@ -39,7 +40,7 @@ SUPPORTED_DIALECTS = {
|
||||
# @BRIEF Extract normalized dialect string from a Superset database record.
|
||||
# @PRE: database_record is a dict from Superset API.
|
||||
# @POST: Returns normalized dialect string or raises ValueError if unsupported.
|
||||
def get_dialect_from_database(database_record: Dict[str, Any]) -> str:
|
||||
def get_dialect_from_database(database_record: dict[str, Any]) -> str:
|
||||
"""Extract and validate dialect from Superset database record."""
|
||||
backend = (
|
||||
database_record.get("backend")
|
||||
@@ -89,7 +90,7 @@ def fetch_datasource_metadata(
|
||||
dataset_id: int,
|
||||
env_id: str,
|
||||
config_manager: ConfigManager,
|
||||
) -> Tuple[List[Dict[str, Any]], str]:
|
||||
) -> tuple[list[dict[str, Any]], str]:
|
||||
"""Fetch column metadata and database dialect for a datasource from Superset."""
|
||||
# Find environment config
|
||||
environments = config_manager.get_environments()
|
||||
@@ -142,7 +143,7 @@ def fetch_datasource_metadata(
|
||||
# @BRIEF Identify virtual (calculated) columns from column metadata.
|
||||
# @PRE: columns is a list of dicts with 'is_physical' key.
|
||||
# @POST: Returns list of virtual column names.
|
||||
def detect_virtual_columns(columns: List[Dict[str, Any]]) -> List[str]:
|
||||
def detect_virtual_columns(columns: list[dict[str, Any]]) -> list[str]:
|
||||
"""Return names of columns that are virtual (not physical)."""
|
||||
return [col["name"] for col in columns if not col.get("is_physical", True)]
|
||||
# #endregion detect_virtual_columns
|
||||
@@ -152,7 +153,7 @@ def detect_virtual_columns(columns: List[Dict[str, Any]]) -> List[str]:
|
||||
# @BRIEF Service for translation job CRUD with validation and Superset integration.
|
||||
class TranslateJobService:
|
||||
|
||||
def __init__(self, db: Session, config_manager: ConfigManager, current_user: Optional[str] = None):
|
||||
def __init__(self, db: Session, config_manager: ConfigManager, current_user: str | None = None):
|
||||
self.db = db
|
||||
self.config_manager = config_manager
|
||||
self.current_user = current_user
|
||||
@@ -164,8 +165,8 @@ class TranslateJobService:
|
||||
self,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
status_filter: Optional[str] = None,
|
||||
) -> Tuple[int, List[TranslationJob]]:
|
||||
status_filter: str | None = None,
|
||||
) -> tuple[int, list[TranslationJob]]:
|
||||
query = self.db.query(TranslationJob)
|
||||
|
||||
if status_filter:
|
||||
@@ -300,7 +301,7 @@ class TranslateJobService:
|
||||
except Exception as e:
|
||||
logger.warning(f"[TranslateJobService] Dialect re-detection failed: {e}")
|
||||
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
job.updated_at = datetime.now(UTC)
|
||||
|
||||
# Update dictionary associations if provided
|
||||
if dict_ids is not None:
|
||||
@@ -343,7 +344,7 @@ class TranslateJobService:
|
||||
# @PURPOSE: Duplicate a translation job with a new name.
|
||||
# @PRE: job_id must exist.
|
||||
# @POST: Returns the duplicated TranslationJob with status DRAFT.
|
||||
def duplicate_job(self, job_id: str, new_name: Optional[str] = None) -> TranslationJob:
|
||||
def duplicate_job(self, job_id: str, new_name: str | None = None) -> TranslationJob:
|
||||
logger.info(f"[TranslateJobService] Duplicating job '{job_id}'")
|
||||
source = self.get_job(job_id)
|
||||
|
||||
@@ -395,7 +396,7 @@ class TranslateJobService:
|
||||
# region get_job_dictionary_ids [TYPE Function]
|
||||
# @PURPOSE: Get dictionary IDs attached to a job.
|
||||
# @POST: Returns list of dictionary IDs.
|
||||
def get_job_dictionary_ids(self, job_id: str) -> List[str]:
|
||||
def get_job_dictionary_ids(self, job_id: str) -> list[str]:
|
||||
assocs = self.db.query(TranslationJobDictionary).filter(
|
||||
TranslationJobDictionary.job_id == job_id
|
||||
).all()
|
||||
@@ -404,7 +405,7 @@ class TranslateJobService:
|
||||
|
||||
# region fetch_available_datasources [TYPE Function]
|
||||
# @PURPOSE: List available Superset datasets for translation job creation.
|
||||
def fetch_available_datasources(self, env_id: str, search: Optional[str] = None) -> list:
|
||||
def fetch_available_datasources(self, env_id: str, search: str | None = None) -> list:
|
||||
"""List Superset datasets available for translation."""
|
||||
from ...core.superset_client import SupersetClient
|
||||
env = self.config_manager.get_environment(env_id)
|
||||
@@ -449,7 +450,7 @@ def _extract_dialect(backend: str) -> str:
|
||||
|
||||
# #region job_to_response [TYPE Function]
|
||||
# @BRIEF Convert a TranslationJob ORM model to a TranslateJobResponse schema with dictionary_ids.
|
||||
def job_to_response(job: TranslationJob, dict_ids: Optional[List[str]] = None) -> TranslateJobResponse:
|
||||
def job_to_response(job: TranslationJob, dict_ids: list[str] | None = None) -> TranslateJobResponse:
|
||||
return TranslateJobResponse(
|
||||
id=job.id,
|
||||
name=job.name,
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
# @REJECTED: UPDATE statements — source is append-only; UPSERT covers overwrite case.
|
||||
# @REJECTED: ORM-based insert bypasses Superset's SQL Lab audit trail.
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from ...core.logger import logger, belief_scope
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from ...core.logger import belief_scope, logger
|
||||
|
||||
# PostgreSQL/Greenplum dialects that support ON CONFLICT
|
||||
POSTGRESQL_DIALECTS = {"postgresql", "redshift", "greenplum"}
|
||||
@@ -22,7 +23,7 @@ CLICKHOUSE_DIALECTS = {"clickhouse"}
|
||||
|
||||
# #region _normalize_timestamp_value [C:2] [TYPE Function] [SEMANTICS translate,sql,timestamp]
|
||||
# @BRIEF Detect Unix timestamp strings (seconds or millis) and convert to 'YYYY-MM-DD' for Date columns.
|
||||
def _normalize_timestamp_value(value: Any) -> Optional[str]:
|
||||
def _normalize_timestamp_value(value: Any) -> str | None:
|
||||
"""Detect Unix timestamp values and convert to date string.
|
||||
|
||||
Handles:
|
||||
@@ -50,7 +51,7 @@ def _normalize_timestamp_value(value: Any) -> Optional[str]:
|
||||
return None
|
||||
|
||||
try:
|
||||
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
|
||||
dt = datetime.fromtimestamp(ts, tz=UTC)
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
except (OSError, OverflowError, ValueError):
|
||||
return None
|
||||
@@ -79,7 +80,7 @@ def _quote_identifier(identifier: str, dialect: str) -> str:
|
||||
|
||||
# #region _encode_sql_value [C:2] [TYPE Function] [SEMANTICS translate,sql,encoding]
|
||||
# @BRIEF Encode a Python value into a SQL-safe literal for INSERT VALUES, with ClickHouse timestamp normalization.
|
||||
def _encode_sql_value(value: Any, dialect: Optional[str] = None) -> str:
|
||||
def _encode_sql_value(value: Any, dialect: str | None = None) -> str:
|
||||
"""Encode a Python value into a SQL-safe literal.
|
||||
|
||||
For ClickHouse dialect, attempts to detect Unix timestamp strings
|
||||
@@ -92,11 +93,7 @@ def _encode_sql_value(value: Any, dialect: Optional[str] = None) -> str:
|
||||
|
||||
# For ClickHouse: try to normalize timestamp-like values (both string and numeric)
|
||||
if dialect in CLICKHOUSE_DIALECTS:
|
||||
if isinstance(value, str) and value:
|
||||
normalized = _normalize_timestamp_value(value)
|
||||
if normalized:
|
||||
return f"'{normalized}'"
|
||||
elif isinstance(value, (int, float)):
|
||||
if (isinstance(value, str) and value) or isinstance(value, (int, float)):
|
||||
normalized = _normalize_timestamp_value(value)
|
||||
if normalized:
|
||||
return f"'{normalized}'"
|
||||
@@ -111,7 +108,7 @@ def _encode_sql_value(value: Any, dialect: Optional[str] = None) -> str:
|
||||
|
||||
# #region _build_values_clause [C:2] [TYPE Function] [SEMANTICS translate,sql,values]
|
||||
# @BRIEF Build a VALUES clause for multiple rows with per-column value encoding and dialect-aware quoting.
|
||||
def _build_values_clause(columns: List[str], rows: List[Dict[str, Any]], dialect: Optional[str] = None) -> str:
|
||||
def _build_values_clause(columns: list[str], rows: list[dict[str, Any]], dialect: str | None = None) -> str:
|
||||
"""Build VALUES (...) clause for multiple rows.
|
||||
|
||||
NOTE: columns may be quoted (e.g. '"col"' or '`col`') for the SQL column list,
|
||||
@@ -135,11 +132,11 @@ def _build_values_clause(columns: List[str], rows: List[Dict[str, Any]], dialect
|
||||
# @RELATION DEPENDS_ON -> [_quote_identifier]
|
||||
# @RELATION DEPENDS_ON -> [_build_values_clause]
|
||||
def generate_insert_sql(
|
||||
target_schema: Optional[str],
|
||||
target_schema: str | None,
|
||||
target_table: str,
|
||||
columns: List[str],
|
||||
rows: List[Dict[str, Any]],
|
||||
dialect: Optional[str] = None,
|
||||
columns: list[str],
|
||||
rows: list[dict[str, Any]],
|
||||
dialect: str | None = None,
|
||||
) -> str:
|
||||
"""Generate a plain INSERT SQL."""
|
||||
with belief_scope("generate_insert_sql"):
|
||||
@@ -167,11 +164,11 @@ def generate_insert_sql(
|
||||
# @PRE: dialect is postgresql-compatible. target_table, columns, key_columns are non-empty.
|
||||
# @POST: Returns UPSERT SQL string or raises ValueError.
|
||||
def generate_upsert_sql(
|
||||
target_schema: Optional[str],
|
||||
target_schema: str | None,
|
||||
target_table: str,
|
||||
columns: List[str],
|
||||
key_columns: List[str],
|
||||
rows: List[Dict[str, Any]],
|
||||
columns: list[str],
|
||||
key_columns: list[str],
|
||||
rows: list[dict[str, Any]],
|
||||
) -> str:
|
||||
"""Generate INSERT ... ON CONFLICT (key_cols) DO UPDATE SET ... SQL."""
|
||||
with belief_scope("generate_upsert_sql"):
|
||||
@@ -225,13 +222,13 @@ class SQLGenerator:
|
||||
@staticmethod
|
||||
def generate(
|
||||
dialect: str,
|
||||
target_schema: Optional[str],
|
||||
target_schema: str | None,
|
||||
target_table: str,
|
||||
columns: List[str],
|
||||
rows: List[Dict[str, Any]],
|
||||
key_columns: Optional[List[str]] = None,
|
||||
columns: list[str],
|
||||
rows: list[dict[str, Any]],
|
||||
key_columns: list[str] | None = None,
|
||||
upsert_strategy: str = "MERGE",
|
||||
) -> Tuple[str, int]:
|
||||
) -> tuple[str, int]:
|
||||
"""Generate dialect-appropriate INSERT/UPSERT SQL.
|
||||
|
||||
Args:
|
||||
@@ -340,14 +337,14 @@ class SQLGenerator:
|
||||
@staticmethod
|
||||
def generate_batch(
|
||||
dialect: str,
|
||||
target_schema: Optional[str],
|
||||
target_schema: str | None,
|
||||
target_table: str,
|
||||
columns: List[str],
|
||||
rows: List[Dict[str, Any]],
|
||||
key_columns: Optional[List[str]] = None,
|
||||
columns: list[str],
|
||||
rows: list[dict[str, Any]],
|
||||
key_columns: list[str] | None = None,
|
||||
upsert_strategy: str = "MERGE",
|
||||
max_rows_per_statement: int = 500,
|
||||
) -> List[Tuple[str, int]]:
|
||||
) -> list[tuple[str, int]]:
|
||||
"""Generate SQL in batches, splitting large row sets into multiple statements.
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -9,14 +9,13 @@
|
||||
# @RATIONALE: Direct SQL Lab API submission provides audit trail and execution monitoring within Superset.
|
||||
# @REJECTED: Direct database connection bypass — would skip Superset's SQL Lab audit and RBAC.
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...core.superset_client import SupersetClient
|
||||
|
||||
|
||||
@@ -30,9 +29,9 @@ class SupersetSqlLabExecutor:
|
||||
def __init__(self, config_manager: ConfigManager, env_id: str):
|
||||
self.config_manager = config_manager
|
||||
self.env_id = env_id
|
||||
self._client: Optional[SupersetClient] = None
|
||||
self._database_id: Optional[int] = None
|
||||
self._database_backend: Optional[str] = None
|
||||
self._client: SupersetClient | None = None
|
||||
self._database_id: int | None = None
|
||||
self._database_backend: str | None = None
|
||||
|
||||
# region _get_client [TYPE Function]
|
||||
# @PURPOSE: Lazy-initialize SupersetClient for the configured environment.
|
||||
@@ -60,8 +59,8 @@ class SupersetSqlLabExecutor:
|
||||
# @SIDE_EFFECT: Fetches databases list from Superset; optionally fetches single DB for backend.
|
||||
def resolve_database_id(
|
||||
self,
|
||||
database_name: Optional[str] = None,
|
||||
target_database_id: Optional[str] = None,
|
||||
database_name: str | None = None,
|
||||
target_database_id: str | None = None,
|
||||
) -> int:
|
||||
with belief_scope("SupersetSqlLabExecutor.resolve_database_id"):
|
||||
client = self._get_client()
|
||||
@@ -124,7 +123,7 @@ class SupersetSqlLabExecutor:
|
||||
# region get_database_backend [TYPE Function]
|
||||
# @PURPOSE: Return the cached database backend/engine string.
|
||||
# @POST: Returns backend string or None if not yet resolved.
|
||||
def get_database_backend(self) -> Optional[str]:
|
||||
def get_database_backend(self) -> str | None:
|
||||
return self._database_backend
|
||||
# endregion get_database_backend
|
||||
|
||||
@@ -136,9 +135,9 @@ class SupersetSqlLabExecutor:
|
||||
def execute_sql(
|
||||
self,
|
||||
sql: str,
|
||||
database_id: Optional[int] = None,
|
||||
database_id: int | None = None,
|
||||
run_async: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
with belief_scope("SupersetSqlLabExecutor.execute_sql"):
|
||||
client = self._get_client()
|
||||
db_id = database_id or self._database_id
|
||||
@@ -174,13 +173,13 @@ class SupersetSqlLabExecutor:
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
if isinstance(response, dict) and response:
|
||||
logger.reason(f"SQL Lab endpoint succeeded", extra={
|
||||
logger.reason("SQL Lab endpoint succeeded", extra={
|
||||
"endpoint": endpoint,
|
||||
})
|
||||
break
|
||||
except Exception as ep_err:
|
||||
last_error = ep_err
|
||||
logger.explore(f"SQL Lab endpoint failed, trying next", extra={
|
||||
logger.explore("SQL Lab endpoint failed, trying next", extra={
|
||||
"error": str(ep_err),
|
||||
"endpoint": endpoint,
|
||||
})
|
||||
@@ -246,7 +245,7 @@ class SupersetSqlLabExecutor:
|
||||
query_id: str,
|
||||
max_polls: int = 60,
|
||||
poll_interval_seconds: float = 2.0,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
with belief_scope("SupersetSqlLabExecutor.poll_execution_status"):
|
||||
client = self._get_client()
|
||||
|
||||
@@ -337,10 +336,10 @@ class SupersetSqlLabExecutor:
|
||||
def execute_and_poll(
|
||||
self,
|
||||
sql: str,
|
||||
database_id: Optional[int] = None,
|
||||
database_id: int | None = None,
|
||||
max_polls: int = 60,
|
||||
poll_interval_seconds: float = 2.0,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
with belief_scope("SupersetSqlLabExecutor.execute_and_poll"):
|
||||
exec_result = self.execute_sql(
|
||||
sql=sql,
|
||||
@@ -389,7 +388,7 @@ class SupersetSqlLabExecutor:
|
||||
# @PRE: query_id is a valid Superset query ID.
|
||||
# @POST: Returns query results if available.
|
||||
# @SIDE_EFFECT: Makes HTTP GET to Superset.
|
||||
def get_query_results(self, query_id: str) -> Dict[str, Any]:
|
||||
def get_query_results(self, query_id: str) -> dict[str, Any]:
|
||||
with belief_scope("SupersetSqlLabExecutor.get_query_results"):
|
||||
client = self._get_client()
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user