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:
2026-05-14 11:20:17 +03:00
parent a9a5eff518
commit c6189876b3
337 changed files with 4677 additions and 4515 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)"

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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]

View File

@@ -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]

View File

@@ -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"

View File

@@ -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(

View File

@@ -2,7 +2,6 @@
# @BRIEF Utility helpers for dictionary management (term normalization and delimiter detection).
# @LAYER: Domain
import re
import unicodedata

View File

@@ -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:

View File

@@ -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)

View File

@@ -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")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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", ""))

View File

@@ -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", {

View File

@@ -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,

View File

@@ -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:

View File

@@ -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: