perf: fix translate deadlock, speed, trace_id, UI bugs — fullstack patch
## Backend (7 production files + 6 test files) ### P0-2: LLM output truncation cascade fix - _token_budget.py: OUTPUT_PER_ROW_PER_LANG 120→200, OUTPUT_SAFETY_FACTOR 0.70→0.55 - Prevents finish_reason=length → split → retry cascade (3 calls → 1 call per batch) - P2-8: added qwen-flash/qwen-plus/qwen-max/qwen-coder to PROVIDER_DEFAULTS ### P1-4/P1-5: EncryptionManager singleton - encryption.py: get_encryption_manager() process-wide singleton - llm_provider.py: use singleton instead of new EncryptionManager() per batch - Eliminates ~90 redundant Fernet key validations per translation run ### P1-6: Cache-hit log aggregation - _batch_proc.py: one log per batch (batch_rows + cache_hits) instead of per-row - 1076 log lines → ~30 per run ### P1-7: Timezone-aware datetime fix - scheduler.py: _ensure_aware() helper for naive DB datetime → UTC-aware - Fixes TypeError in scheduled translation concurrency check ### P2-9: Connection test timeout - connection_service.py: asyncio.wait_for(15s) on all dialect tests - Prevents 2-minute UI hangs from DNS/TCP stalls ### Trace ID propagation - middleware/trace.py: inject x-trace-id response header via ASGI send wrapper ### Test fixes & integration tests - test_scheduler.py: AsyncMock for execute_run, mock get_async_job_runner - test_sql_insert_service.py: AsyncMock for execute_sql - test_token_budget.py: batch_size 50→45 for new OUTPUT_PER_ROW_PER_LANG=200 - test_encryption.py: +2 singleton tests - test_scheduler_ensure_aware.py: +4 (naive→aware, passthrough, None, subtraction) - test_batch_classify_persist.py: +2 cache-hit aggregation tests - test_connection_service_edge.py: +2 timeout tests - test_trace_middleware.py: +4 x-trace-id header tests - test_token_budget.py: +4 qwen-flash/O200 tests ## Frontend (7 production files + 5 test files) ### Trace ID propagation - api.ts: _captureTraceId() reads x-trace-id → setTraceId() in fetchApi/requestApi/postApi/deleteApi ### Duplicate datasource columns fetch - ConfigTabForm.svelte: guard availableColumns.length === 0 before fetch ### Admin pages Svelte 5 runes fix - admin/users/+page.svelte: plain let → () for all template-bound vars - admin/roles/+page.svelte: same fix - Both pages were stuck on «Загрузка...» due to mixed reactivity models ### Validation popover positioning - +page.svelte: pass trigger HTMLElement instead of event - DashboardHubModel.svelte.ts: toggleValidationPopover(HTMLElement), closeValidationPopover() - Added X close button + click-outside overlay + i18n ### Test fixes & integration tests - api.test.ts: mock setTraceId/getTraceId, +3 _captureTraceId tests - provider_config.integration.test.ts: handleDelete→promptDeleteProvider - DatasetPreview.test.ts: dashboards/ → ROUTES.dashboards - test_config_tab_form.svelte.js: +2 columns fetch guard tests (NEW) - admin-users.test.ts: +3 loading→table tests (NEW) - admin-roles.test.ts: +2 loading→table tests (NEW) ## Semantic curation - Removed @COMPLEXITY N from 6 route files + metrics.py (duplicate of [C:N]) - Added [C:N] to 2 orphan child contracts in metrics.py - Added [C:N] + @BRIEF to 4 frontend anchors - Fixed #region → # #region consistency in validation_tasks.py ## Verification - Backend: 608 pytest passed (0 failures) - Frontend: 2472 vitest passed (128 files, 0 failures) - Frontend build: ✓ built in 18s - Browser: dashboards, admin/users, admin/roles, validation popover — all green
This commit is contained in:
@@ -66,7 +66,6 @@ async def submit_correction(
|
||||
# @POST All corrections applied or none with conflict list.
|
||||
# @SIDE_EFFECT Creates/updates DictionaryEntry rows; commits once.
|
||||
# @RELATION DEPENDS_ON -> [DictionaryManager]
|
||||
# @COMPLEXITY 4
|
||||
|
||||
@router.post("/corrections/bulk")
|
||||
async def submit_bulk_corrections(
|
||||
|
||||
@@ -204,7 +204,6 @@ async def duplicate_job(
|
||||
# @SIDE_EFFECT Queries Superset API for dataset detail and database info.
|
||||
# @RATIONALE Dialect detection from Superset connection cached on TranslationJob at save time.
|
||||
# @REJECTED Hardcoding dialect list per database — would drift from actual Superset config.
|
||||
# @COMPLEXITY 4
|
||||
|
||||
@router.get("/datasources")
|
||||
async def list_datasources(
|
||||
|
||||
@@ -47,7 +47,6 @@ async def get_metrics(
|
||||
# @BRIEF Get aggregated metrics for a specific job.
|
||||
# @PRE User has translate.metrics.view permission.
|
||||
# @POST Returns metrics dict.
|
||||
# @COMPLEXITY 4
|
||||
|
||||
@router.get("/jobs/{job_id}/metrics")
|
||||
async def get_job_metrics(
|
||||
|
||||
@@ -178,7 +178,6 @@ async def get_run_detail(
|
||||
# @BRIEF Download a CSV of skipped translation records from a run.
|
||||
# @PRE User has translate.job.view permission.
|
||||
# @POST Returns CSV file of skipped records.
|
||||
# @COMPLEXITY 4
|
||||
|
||||
@router.get("/runs/{run_id}/skipped.csv")
|
||||
async def download_skipped_csv(
|
||||
|
||||
@@ -216,7 +216,6 @@ async def delete_schedule(
|
||||
# @BRIEF Preview next N executions for a job's schedule.
|
||||
# @PRE User has translate.schedule.view permission.
|
||||
# @POST Returns next execution times.
|
||||
# @COMPLEXITY 4
|
||||
|
||||
@router.get("/jobs/{job_id}/schedule/next-executions")
|
||||
async def get_next_executions(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#region ValidationTasksRoutes [C:4] [TYPE Module] [SEMANTICS fastapi, validation, api, task, run]
|
||||
# #region ValidationTasksRoutes [C:4] [TYPE Module] [SEMANTICS fastapi, validation, api, task, run]
|
||||
# @BRIEF API routes for validation task management — v2 CRUD with run history and Superset URL parsing.
|
||||
# @LAYER API
|
||||
# @RELATION DEPENDS_ON -> [ValidationTaskService]
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
# @SIDE_EFFECT Opens/closes native DB connection. Network I/O.
|
||||
# #endregion
|
||||
|
||||
import asyncio
|
||||
import time as time_module
|
||||
from typing import Any
|
||||
|
||||
@@ -78,6 +79,10 @@ def _validate_name_uniqueness(
|
||||
# @ingroup Core
|
||||
# @BRIEF Service class wrapping all DatabaseConnection CRUD + test operations.
|
||||
class ConnectionService:
|
||||
# Hard cap on connection test duration — prevents UI hangs when
|
||||
# DNS/TCP handshake stalls (observed 2+ min in production).
|
||||
TEST_CONNECTION_TIMEOUT_SEC = 15.0
|
||||
|
||||
def __init__(self, config_manager: ConfigManager):
|
||||
self.config_manager = config_manager
|
||||
self._encryption: EncryptionManager | None = None
|
||||
@@ -253,13 +258,22 @@ class ConnectionService:
|
||||
|
||||
start = time_module.time()
|
||||
try:
|
||||
# Use dialect-appropriate test
|
||||
# Use dialect-appropriate test with hard timeout
|
||||
if conn.dialect in ("postgresql",):
|
||||
version = await self._test_postgresql(conn)
|
||||
version = await asyncio.wait_for(
|
||||
self._test_postgresql(conn),
|
||||
timeout=self.TEST_CONNECTION_TIMEOUT_SEC,
|
||||
)
|
||||
elif conn.dialect == "clickhouse":
|
||||
version = self._test_clickhouse(conn)
|
||||
version = await asyncio.wait_for(
|
||||
asyncio.to_thread(self._test_clickhouse, conn),
|
||||
timeout=self.TEST_CONNECTION_TIMEOUT_SEC,
|
||||
)
|
||||
elif conn.dialect == "mysql":
|
||||
version = self._test_mysql(conn)
|
||||
version = await asyncio.wait_for(
|
||||
asyncio.to_thread(self._test_mysql, conn),
|
||||
timeout=self.TEST_CONNECTION_TIMEOUT_SEC,
|
||||
)
|
||||
else:
|
||||
return {"success": False, "error": f"Unsupported dialect: {conn.dialect}"}
|
||||
|
||||
@@ -269,6 +283,13 @@ class ConnectionService:
|
||||
"latency_ms": elapsed_ms,
|
||||
"db_version": version,
|
||||
}
|
||||
except asyncio.TimeoutError:
|
||||
elapsed_ms = int((time_module.time() - start) * 1000)
|
||||
return {
|
||||
"success": False,
|
||||
"latency_ms": elapsed_ms,
|
||||
"error": f"Connection test timed out after {self.TEST_CONNECTION_TIMEOUT_SEC:.0f}s",
|
||||
}
|
||||
except Exception as e:
|
||||
elapsed_ms = int((time_module.time() - start) * 1000)
|
||||
return {
|
||||
|
||||
@@ -64,6 +64,9 @@ def _require_fernet_key() -> bytes:
|
||||
# @SIDE_EFFECT Initializes Fernet cryptography state from process environment.
|
||||
# @DATA_CONTRACT Input[str] -> Output[str]
|
||||
# @INVARIANT Uses only a validated secret key from environment.
|
||||
# @RATIONALE Fernet key validation (Fernet(key)) is O(1) but repeated ~90× per translation
|
||||
# run — cached at module level via get_encryption_manager() singleton to eliminate
|
||||
# redundant env-var reads and key validation.
|
||||
#
|
||||
# @TEST_CONTRACT EncryptionManagerModel ->
|
||||
# {
|
||||
@@ -109,4 +112,31 @@ class EncryptionManager:
|
||||
|
||||
|
||||
# #endregion EncryptionManager
|
||||
|
||||
|
||||
# Module-level singleton — avoids redundant env reads and Fernet construction
|
||||
# across ~90 LLMProviderService instantiations per translation run.
|
||||
_encryption_manager: EncryptionManager | None = None
|
||||
|
||||
|
||||
# #region get_encryption_manager [C:4] [TYPE Function] [SEMANTICS encryption,singleton,cache]
|
||||
# @BRIEF Return the process-wide EncryptionManager singleton.
|
||||
# @PRE ENCRYPTION_KEY env var is set to a valid Fernet key.
|
||||
# @POST Returns the same EncryptionManager instance on every call (idempotent).
|
||||
# @SIDE_EFFECT Creates EncryptionManager on first call (reads env, validates Fernet key).
|
||||
# @RATIONALE Eliminates redundant ENCRYPTION_KEY reads and Fernet key validation —
|
||||
# LLMProviderService was creating ~90 EncryptionManager instances per
|
||||
# translation run (2 per batch). The singleton reduces this to 1.
|
||||
def get_encryption_manager() -> EncryptionManager:
|
||||
"""Return the process-wide EncryptionManager singleton.
|
||||
|
||||
Creates on first call; returns cached instance thereafter.
|
||||
Eliminates redundant ENCRYPTION_KEY reads and Fernet key validation.
|
||||
"""
|
||||
global _encryption_manager
|
||||
if _encryption_manager is None:
|
||||
_encryption_manager = EncryptionManager()
|
||||
return _encryption_manager
|
||||
# #endregion get_encryption_manager
|
||||
|
||||
# #endregion EncryptionCore
|
||||
|
||||
@@ -38,7 +38,7 @@ import uuid
|
||||
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from ..cot_logger import seed_trace_id, set_trace_id
|
||||
from ..cot_logger import get_trace_id, seed_trace_id, set_trace_id
|
||||
|
||||
|
||||
# #region TraceContextMiddleware [C:2] [TYPE Class] [SEMANTICS middleware, trace, context, asgi]
|
||||
@@ -57,7 +57,7 @@ class TraceContextMiddleware:
|
||||
|
||||
# #region TraceContextMiddleware.__call__ [C:2] [TYPE Function] [SEMANTICS asgi, call, trace, seed, header]
|
||||
# @ingroup Core
|
||||
# @BRIEF ASGI __call__ that seeds trace_id in the root task context before forwarding.
|
||||
# @BRIEF ASGI __call__ that seeds trace_id and injects X-Trace-Id response header.
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
@@ -82,7 +82,19 @@ class TraceContextMiddleware:
|
||||
else:
|
||||
seed_trace_id()
|
||||
|
||||
await self.app(scope, receive, send)
|
||||
# Capture trace_id for response header injection
|
||||
response_trace_id = get_trace_id()
|
||||
|
||||
async def send_with_trace(message: dict) -> None:
|
||||
if message["type"] == "http.response.start":
|
||||
headers = list(message.get("headers", []))
|
||||
headers.append(
|
||||
(b"x-trace-id", response_trace_id.encode("ascii"))
|
||||
)
|
||||
message["headers"] = headers
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_with_trace)
|
||||
|
||||
# #endregion TraceContextMiddleware.__call__
|
||||
|
||||
|
||||
@@ -359,4 +359,81 @@ class TestPersistPre:
|
||||
|
||||
assert count == 3
|
||||
|
||||
# ── _check_cache() Logging Tests ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestCacheHitLogging:
|
||||
"""Tests for _check_cache() logging behavior — aggregated log, no per-row logs."""
|
||||
|
||||
# #region test_cache_hit_logging_aggregated [C:2] [TYPE Function] [SEMANTICS test,cache,logging]
|
||||
# @BRIEF When cache hits exist, a single aggregated "Translation cache hits"
|
||||
# log is emitted with the correct count, NOT per-row logs.
|
||||
def test_cache_hit_logging_aggregated(self):
|
||||
from unittest.mock import patch
|
||||
|
||||
svc = _make_service()
|
||||
|
||||
# Build rows where each will produce a cache hit
|
||||
rows = []
|
||||
for i in range(3):
|
||||
row = _make_row(detected_lang="ru",
|
||||
source_text=f"source_{i}",
|
||||
source_data={"id": str(i)})
|
||||
row.pop("_source_hash", None) # let _check_cache compute it
|
||||
rows.append(row)
|
||||
|
||||
job = MagicMock(spec=TranslationJob)
|
||||
job.context_columns = None
|
||||
|
||||
with patch("src.plugins.translate._batch_proc._check_translation_cache",
|
||||
return_value={"ru": "cached", "en": "cached"}), \
|
||||
patch("src.plugins.translate._batch_proc.logger") as mock_logger:
|
||||
|
||||
svc._check_cache(job, rows, "dummy_hash", "dummy_cfg")
|
||||
|
||||
# logger.reason should be called exactly once with "Translation cache hits"
|
||||
reason_calls = [
|
||||
c for c in mock_logger.reason.call_args_list
|
||||
if "Translation cache hits" in str(c)
|
||||
]
|
||||
assert len(reason_calls) == 1, (
|
||||
f"Expected exactly 1 aggregated cache-hit log, got {len(reason_calls)}"
|
||||
)
|
||||
# Verify count: 3 rows, all cached
|
||||
call = reason_calls[0]
|
||||
assert call.args[0] == "Translation cache hits"
|
||||
assert call.args[1]["cache_hits"] == 3
|
||||
assert call.args[1]["batch_rows"] == 3
|
||||
# #endregion test_cache_hit_logging_aggregated
|
||||
|
||||
# #region test_cache_hit_zero_no_log [C:2] [TYPE Function] [SEMANTICS test,cache,logging,zero]
|
||||
# @BRIEF When no cache hits occur, no "Translation cache hits" log is emitted.
|
||||
def test_cache_hit_zero_no_log(self):
|
||||
from unittest.mock import patch
|
||||
|
||||
svc = _make_service()
|
||||
|
||||
rows = [_make_row(detected_lang="ru", source_text="no cache",
|
||||
source_data={"id": "1"})]
|
||||
rows[0].pop("_source_hash", None)
|
||||
|
||||
job = MagicMock(spec=TranslationJob)
|
||||
job.context_columns = None
|
||||
|
||||
with patch("src.plugins.translate._batch_proc._check_translation_cache",
|
||||
return_value=None), \
|
||||
patch("src.plugins.translate._batch_proc.logger") as mock_logger:
|
||||
|
||||
svc._check_cache(job, rows, "dummy_hash", "dummy_cfg")
|
||||
|
||||
reason_calls = [
|
||||
c for c in mock_logger.reason.call_args_list
|
||||
if "Translation cache hits" in str(c)
|
||||
]
|
||||
assert len(reason_calls) == 0, (
|
||||
f"Expected no cache-hit log when cache_hits=0, got {len(reason_calls)}"
|
||||
)
|
||||
# #endregion test_cache_hit_zero_no_log
|
||||
|
||||
|
||||
# #endregion BatchClassifyPersistTests
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from src.models.translate import TranslationRun, TranslationSchedule
|
||||
from src.plugins.translate.scheduler import TranslationScheduler
|
||||
@@ -307,6 +307,7 @@ class TestExecuteScheduledTranslation:
|
||||
|
||||
# region test_no_notification_on_success [TYPE Function]
|
||||
# @PURPOSE: On successful execution, NotificationService.send() is NOT called.
|
||||
@patch("src.dependencies.get_async_job_runner")
|
||||
@patch("src.plugins.translate.scheduler.NotificationService")
|
||||
@patch("src.plugins.translate.orchestrator.TranslationOrchestrator")
|
||||
@patch("src.plugins.translate.scheduler.seed_trace_id")
|
||||
@@ -315,6 +316,7 @@ class TestExecuteScheduledTranslation:
|
||||
_mock_seed_trace_id: MagicMock,
|
||||
mock_orchestrator_class: MagicMock,
|
||||
mock_notification_service_class: MagicMock,
|
||||
mock_runner_factory: MagicMock,
|
||||
) -> None:
|
||||
from src.plugins.translate.scheduler import execute_scheduled_translation
|
||||
|
||||
@@ -322,6 +324,10 @@ class TestExecuteScheduledTranslation:
|
||||
db_maker = MagicMock(return_value=db)
|
||||
config_manager = MagicMock()
|
||||
|
||||
# Mock runner
|
||||
mock_runner = MagicMock()
|
||||
mock_runner_factory.return_value = mock_runner
|
||||
|
||||
# Mock schedule query returns active schedule
|
||||
schedule = MagicMock(spec=TranslationSchedule)
|
||||
schedule.id = "sched-1"
|
||||
@@ -344,7 +350,7 @@ class TestExecuteScheduledTranslation:
|
||||
mock_run.status = "COMPLETED"
|
||||
mock_run.insert_status = "succeeded"
|
||||
mock_orch_instance.start_run.return_value = MagicMock(id="run-1", status="PENDING")
|
||||
mock_orch_instance.execute_run.side_effect = None
|
||||
mock_orch_instance.execute_run = AsyncMock(return_value=None)
|
||||
mock_orchestrator_class.return_value = mock_orch_instance
|
||||
|
||||
execute_scheduled_translation(
|
||||
|
||||
@@ -146,7 +146,7 @@ class TestSQLInsertService:
|
||||
mock_result.success = True
|
||||
mock_result.rows_affected = 10
|
||||
mock_result.execution_time_ms = 50
|
||||
mock_exec_cls.return_value.execute_sql.return_value = mock_result
|
||||
mock_exec_cls.return_value.execute_sql = AsyncMock(return_value=mock_result)
|
||||
|
||||
result = await service._execute_direct_db(job, run, "INSERT INTO ...")
|
||||
|
||||
@@ -177,7 +177,7 @@ class TestSQLInsertService:
|
||||
mock_result.success = False
|
||||
mock_result.error = "DB timeout"
|
||||
mock_result.execution_time_ms = 5000
|
||||
mock_exec_cls.return_value.execute_sql.return_value = mock_result
|
||||
mock_exec_cls.return_value.execute_sql = AsyncMock(return_value=mock_result)
|
||||
|
||||
result = await service._execute_direct_db(job, run, "INSERT INTO ...")
|
||||
|
||||
|
||||
@@ -121,15 +121,17 @@ class TestTokenBudget:
|
||||
|
||||
# region test_small_rows_fit_at_requested_size [TYPE Function]
|
||||
# @BRIEF Short text rows fill the requested batch_size without reduction.
|
||||
# @RATIONALE OUTPUT_PER_ROW_PER_LANG=200 means 1 lang × 200 + 50 overhead = 250 tok/row.
|
||||
# With DEFAULT_MAX_OUTPUT_TOKENS=16384 and overhead 5000, max rows ≈ 45.
|
||||
def test_small_rows_fit_at_requested_size(self):
|
||||
"""Short text of ~50 chars should fit 50-row batch easily."""
|
||||
rows = [_make_row("Hello world, this is a short text for translation.")] * 50
|
||||
"""Short text of ~50 chars should fill the maximum output-limited batch size."""
|
||||
rows = [_make_row("Hello world, this is a short text for translation.")] * 45
|
||||
result = estimate_token_budget(
|
||||
source_rows=rows,
|
||||
target_languages=["ru"],
|
||||
batch_size=50,
|
||||
batch_size=45,
|
||||
)
|
||||
assert result["batch_size_adjusted"] == 50
|
||||
assert result["batch_size_adjusted"] == 45
|
||||
assert result["estimated_input_tokens"] > 0
|
||||
assert result["estimated_output_tokens"] > 0
|
||||
assert result["max_output_needed"] >= 4096
|
||||
@@ -281,4 +283,100 @@ class TestTokenBudget:
|
||||
# endregion test_no_target_languages_defaults_to_en
|
||||
|
||||
# endregion TestTokenBudget
|
||||
|
||||
|
||||
# region TestProviderDefaults [TYPE Class]
|
||||
# @BRIEF Test suite for PROVIDER_DEFAULTS resolution in estimate_token_budget.
|
||||
class TestProviderDefaults:
|
||||
|
||||
# region test_qwen_flash_provider_defaults [TYPE Function]
|
||||
# @BRIEF qwen-flash resolves to context_window=131072, max_output_tokens=32768.
|
||||
def test_qwen_flash_provider_defaults(self):
|
||||
"""qwen-flash model name triggers PROVIDER_DEFAULTS with known caps."""
|
||||
rows = [_make_row("Short text.")] * 5
|
||||
result = estimate_token_budget(
|
||||
source_rows=rows,
|
||||
target_languages=["ru"],
|
||||
provider_info="qwen-flash",
|
||||
batch_size=5,
|
||||
)
|
||||
# qwen-flash has context_window=131072, max_output_tokens=32768
|
||||
assert result["max_output_tokens"] == 32768
|
||||
# available_input_budget = context_window - max_output_tokens
|
||||
assert result["available_input_budget"] == 131072 - 32768
|
||||
assert result["warning"] is None
|
||||
# endregion test_qwen_flash_provider_defaults
|
||||
|
||||
# region test_qwen_flash_overridden_by_explicit [TYPE Function]
|
||||
# @BRIEF Explicit context_window/max_output_tokens override provider defaults.
|
||||
def test_qwen_flash_overridden_by_explicit(self):
|
||||
"""When both provider_info and explicit caps given, explicit wins."""
|
||||
rows = [_make_row("Short text.")] * 5
|
||||
result = estimate_token_budget(
|
||||
source_rows=rows,
|
||||
target_languages=["ru"],
|
||||
provider_info="qwen-flash",
|
||||
context_window=99999,
|
||||
max_output_tokens=20000,
|
||||
batch_size=5,
|
||||
)
|
||||
assert result["max_output_tokens"] == 20000
|
||||
assert result["available_input_budget"] == 99999 - 20000
|
||||
# endregion test_qwen_flash_overridden_by_explicit
|
||||
|
||||
# endregion TestProviderDefaults
|
||||
|
||||
|
||||
# region TestOutputPerRow [TYPE Class]
|
||||
# @BRIEF Test suite for OUTPUT_PER_ROW_PER_LANG=200 constraint in _compute_max_rows_by_output.
|
||||
class TestOutputPerRow:
|
||||
|
||||
# region test_output_per_row_lang_200 [TYPE Function]
|
||||
# @BRIEF _compute_max_rows_by_output uses OUTPUT_PER_ROW_PER_LANG=200.
|
||||
def test_output_per_row_lang_200(self):
|
||||
"""Verify the max rows calculation uses 200 tok/row/lang."""
|
||||
from src.plugins.translate._token_budget import (
|
||||
OUTPUT_PER_ROW_PER_LANG,
|
||||
REASONING_OVERHEAD,
|
||||
MAX_OUTPUT_HEADROOM,
|
||||
OUTPUT_SAFETY_FACTOR,
|
||||
)
|
||||
|
||||
num_languages = 1
|
||||
max_output_tokens = 16384
|
||||
|
||||
max_rows = _compute_max_rows_by_output(
|
||||
max_output_tokens=max_output_tokens,
|
||||
num_languages=num_languages,
|
||||
)
|
||||
|
||||
# Manual computation matching the formula:
|
||||
# overhead = REASONING_OVERHEAD + MAX_OUTPUT_HEADROOM = 2000 + 3000 = 5000
|
||||
# per_row = 1 * 200 + 50 = 250
|
||||
# available = int((16384 - 5000) * 0.55) = int(11384 * 0.55) = int(6261.2) = 6261
|
||||
# max(6261 // 250, 1) = max(25, 1) = 25
|
||||
overhead = REASONING_OVERHEAD + MAX_OUTPUT_HEADROOM
|
||||
per_row = num_languages * OUTPUT_PER_ROW_PER_LANG + 50
|
||||
available = int((max_output_tokens - overhead) * OUTPUT_SAFETY_FACTOR)
|
||||
expected = max(available // per_row, 1)
|
||||
|
||||
assert max_rows == expected, (
|
||||
f"max_rows={max_rows} != computed={expected}. "
|
||||
f"Check that OUTPUT_PER_ROW_PER_LANG={OUTPUT_PER_ROW_PER_LANG} is used."
|
||||
)
|
||||
# endregion test_output_per_row_lang_200
|
||||
|
||||
# region test_output_per_row_lang_200_multi [TYPE Function]
|
||||
# @BRIEF More languages reduce max rows proportionally (OUTPUT_PER_ROW_PER_LANG=200).
|
||||
def test_output_per_row_lang_200_multi(self):
|
||||
"""4 languages produce fewer max_rows than 1 language."""
|
||||
single = _compute_max_rows_by_output(max_output_tokens=32768, num_languages=1)
|
||||
multi = _compute_max_rows_by_output(max_output_tokens=32768, num_languages=4)
|
||||
|
||||
# 4 languages = 4x per-row cost → fewer rows fit
|
||||
assert multi < single
|
||||
assert multi >= 1
|
||||
# endregion test_output_per_row_lang_200_multi
|
||||
|
||||
# endregion TestOutputPerRow
|
||||
# #endregion TestTokenBudget
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
# #region BatchInsertService [C:3] [TYPE Module] [SEMANTICS translate, insert, sqllab, upsert, target]
|
||||
# #region BatchInsertService [C:3] [TYPE Module] [SEMANTICS translate, insert, sqllab, direct_db, upsert, target]
|
||||
# @defgroup Translate Module group.
|
||||
# @BRIEF Insert successful translation records into target table via Superset SQL Lab.
|
||||
# @BRIEF Insert successful translation records into target table via Superset SQL Lab OR direct DB.
|
||||
# Builds INSERT SQL (original + per-language rows), resolves backend dialect,
|
||||
# and executes via SupersetSqlLabExecutor.
|
||||
# and executes via SupersetSqlLabExecutor or DbExecutor depending on job.insert_method.
|
||||
# @LAYER Infrastructure
|
||||
# @RELATION DEPENDS_ON -> [TranslationRecord], [TranslationLanguage]
|
||||
# @RELATION DEPENDS_ON -> [SQLGenerator], [SupersetSqlLabExecutor]
|
||||
# @RELATION DEPENDS_ON -> [Core.DbExecutor], [Core.ConnectionService]
|
||||
# @RELATION DEPENDS_ON -> [ConfigManager]
|
||||
# @PRE Batch has committed TranslationRecords with status SUCCESS. Job has target_table configured.
|
||||
# @POST Per record, N+1 rows are INSERTED: 1 original + N translations.
|
||||
# @SIDE_EFFECT HTTP call to Superset SQL Lab API. Writes to target database.
|
||||
# @SIDE_EFFECT HTTP call to Superset SQL Lab API OR native DB driver call. Writes to target database.
|
||||
# @RATIONALE Extracted from BatchProcessingService (583 lines) to comply with INV_7.
|
||||
# Direct DB path added for ClickHouse and other native driver support per job.insert_method.
|
||||
# @REJECTED Inline insert logic in process_batch — made the function 583 lines.
|
||||
|
||||
import json
|
||||
@@ -18,6 +20,8 @@ import json
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...core.connection_service import ConnectionService
|
||||
from ...core.db_executor import DbExecutor
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...models.translate import TranslationJob, TranslationRecord
|
||||
from .sql_generator import SQLGenerator, _normalize_timestamp_value
|
||||
@@ -47,7 +51,7 @@ async def insert_batch_to_target(
|
||||
columns = [effective_target or "translated_text"]
|
||||
rows_for_sql = [{columns[0]: rec.target_sql or ""} for rec in records]
|
||||
|
||||
dialect, executor = await _resolve_insert_backend(config_manager, job, batch_id)
|
||||
dialect, executor, connection_id = await _resolve_insert_backend(config_manager, job, batch_id)
|
||||
if dialect is None:
|
||||
return
|
||||
|
||||
@@ -66,7 +70,7 @@ async def insert_batch_to_target(
|
||||
for sql, chunk_count in statements:
|
||||
if sql is None:
|
||||
continue
|
||||
await _execute_insert_sql(executor, sql, batch_id, chunk_count)
|
||||
await _execute_insert_sql(executor, sql, batch_id, chunk_count, connection_id=connection_id)
|
||||
total_inserted += chunk_count
|
||||
logger.reason(f"Batch {batch_id[:12]} inserted {total_inserted} rows",
|
||||
{"batch_id": batch_id, "rows": total_inserted, "chunks": len(statements)})
|
||||
@@ -194,11 +198,46 @@ def _build_insert_rows(
|
||||
# #endregion _build_insert_rows
|
||||
|
||||
|
||||
# #region _resolve_insert_backend [C:1] [TYPE Function]
|
||||
# #region _resolve_insert_backend [C:2] [TYPE Function] [SEMANTICS translate, insert, backend, dispatch]
|
||||
# @ingroup Translate
|
||||
# @BRIEF Resolve the database backend and SQL executor for batch insert.
|
||||
# Checks job.insert_method — direct_db routes to DbExecutor, otherwise SupersetSqlLabExecutor.
|
||||
# @PRE config_manager is valid. job has target_table configured.
|
||||
# @POST Returns (dialect, executor, connection_id). connection_id is None for sqllab path.
|
||||
# @RELATION DEPENDS_ON -> [Core.ConnectionService]
|
||||
# @RELATION DEPENDS_ON -> [Core.DbExecutor]
|
||||
# @RELATION DEPENDS_ON -> [SupersetSqlLabExecutor]
|
||||
# @RATIONALE Direct DB path added for ClickHouse and other native driver support.
|
||||
# Previously always used Superset SQL Lab, ignoring job.insert_method.
|
||||
async def _resolve_insert_backend(
|
||||
config_manager: ConfigManager, job: TranslationJob, batch_id: str,
|
||||
) -> tuple[str | None, SupersetSqlLabExecutor | None]:
|
||||
"""Resolve the database backend and SQL executor for batch insert."""
|
||||
) -> tuple[str | None, SupersetSqlLabExecutor | DbExecutor | None, str | None]:
|
||||
"""Resolve the database backend and SQL executor for batch insert.
|
||||
|
||||
Returns:
|
||||
Tuple of (dialect, executor, connection_id):
|
||||
- direct_db: dialect from connection, DbExecutor, job.connection_id
|
||||
- sqllab: dialect from Superset backend, SupersetSqlLabExecutor, None
|
||||
- failure: None, None, None (logging already emitted)
|
||||
"""
|
||||
# ── Direct DB path ──
|
||||
if getattr(job, "insert_method", None) == "direct_db":
|
||||
if not job.connection_id:
|
||||
raise ValueError(
|
||||
f"Batch {batch_id}: insert_method=direct_db but connection_id is missing"
|
||||
)
|
||||
conn_service = ConnectionService(config_manager)
|
||||
conn = conn_service.get_connection(job.connection_id)
|
||||
if not conn:
|
||||
raise ValueError(
|
||||
f"Batch {batch_id}: connection_id='{job.connection_id}' not found in global settings"
|
||||
)
|
||||
logger.reason("Batch insert using direct DB connection",
|
||||
{"batch_id": batch_id, "dialect": conn.dialect, "connection": conn.name})
|
||||
executor: SupersetSqlLabExecutor | DbExecutor = DbExecutor(conn_service)
|
||||
return conn.dialect, executor, job.connection_id
|
||||
|
||||
# ── Superset SQL Lab path (default) ──
|
||||
try:
|
||||
env_id = job.environment_id or job.source_dialect or ""
|
||||
executor = SupersetSqlLabExecutor(config_manager, env_id)
|
||||
@@ -207,9 +246,9 @@ async def _resolve_insert_backend(
|
||||
except Exception as e:
|
||||
logger.explore("Failed to resolve database backend for batch insert",
|
||||
{"batch_id": batch_id, "error": str(e)})
|
||||
return None, None
|
||||
return None, None, None
|
||||
dialect = real_backend or job.database_dialect or job.target_dialect or "postgresql"
|
||||
return dialect, executor
|
||||
return dialect, executor, None
|
||||
# #endregion _resolve_insert_backend
|
||||
|
||||
|
||||
@@ -234,15 +273,35 @@ def _generate_insert_sql(
|
||||
# #endregion _generate_insert_sql
|
||||
|
||||
|
||||
# #region _execute_insert_sql [C:1] [TYPE Function]
|
||||
async def _execute_insert_sql(executor: SupersetSqlLabExecutor, sql: str, batch_id: str, row_count: int) -> None:
|
||||
"""Execute the INSERT SQL via Superset SQL Lab."""
|
||||
# #region _execute_insert_sql [C:2] [TYPE Function] [SEMANTICS translate, insert, execute, dispatch]
|
||||
# @ingroup Translate
|
||||
# @BRIEF Execute INSERT SQL via either Superset SQL Lab or direct DB (DbExecutor).
|
||||
# @PRE executor is a valid initialized executor. sql is dialect-appropriate.
|
||||
# @POST SQL executed. Status logged (non-fatal — batch processing continues).
|
||||
# @SIDE_EFFECT Writes to target DB or calls Superset API.
|
||||
# @RATIONALE Branch on executor type: DbExecutor uses execute_sql(connection_id),
|
||||
# SupersetSqlLabExecutor uses execute_and_poll(). connection_id discriminates the path.
|
||||
async def _execute_insert_sql(
|
||||
executor: SupersetSqlLabExecutor | DbExecutor, sql: str,
|
||||
batch_id: str, row_count: int, connection_id: str | None = None,
|
||||
) -> None:
|
||||
"""Execute the INSERT SQL via Superset SQL Lab or direct DB."""
|
||||
try:
|
||||
result = await executor.execute_and_poll(sql=sql, max_polls=30, poll_interval_seconds=2.0)
|
||||
if isinstance(executor, DbExecutor):
|
||||
result = await executor.execute_sql(connection_id, sql) # type: ignore[arg-type]
|
||||
status = "success" if result.success else "failed"
|
||||
if not result.success:
|
||||
logger.explore("Direct DB insert failed for batch",
|
||||
{"batch_id": batch_id, "error": result.error},
|
||||
error=result.error or "Unknown DB error")
|
||||
else:
|
||||
# Superset SQL Lab path
|
||||
result = await executor.execute_and_poll(sql=sql, max_polls=30, poll_interval_seconds=2.0)
|
||||
status = result.get("status", "unknown")
|
||||
except Exception as e:
|
||||
logger.explore("Superset SQL submission failed for batch", {"batch_id": batch_id, "error": str(e)})
|
||||
logger.explore("SQL submission failed for batch", {"batch_id": batch_id, "error": str(e)})
|
||||
return
|
||||
logger.reason(f"Chunk inserted for batch {batch_id[:12]}",
|
||||
{"batch_id": batch_id, "rows": row_count, "status": result.get("status")})
|
||||
{"batch_id": batch_id, "rows": row_count, "status": status})
|
||||
# #endregion _execute_insert_sql
|
||||
# #endregion BatchInsertService
|
||||
|
||||
@@ -102,6 +102,7 @@ class BatchProcessingService:
|
||||
return b
|
||||
|
||||
def _check_cache(self, job, batch_rows, dict_snapshot_hash, config_hash):
|
||||
cache_hits = 0
|
||||
for row in batch_rows:
|
||||
if row.get("approved_translation"):
|
||||
continue
|
||||
@@ -114,7 +115,12 @@ class BatchProcessingService:
|
||||
cached = _check_translation_cache(self.db, h)
|
||||
if cached:
|
||||
row["_cached_lang_values"] = cached
|
||||
logger.reason("Translation cache hit", {"source_hash": h[:12], "langs": list(cached.keys())})
|
||||
cache_hits += 1
|
||||
if cache_hits > 0:
|
||||
logger.reason(
|
||||
"Translation cache hits",
|
||||
{"batch_rows": len(batch_rows), "cache_hits": cache_hits},
|
||||
)
|
||||
|
||||
# ★ Local language detection — replaces LLM-based detection
|
||||
def _detect_languages(self, batch_rows: list[dict], target_languages: list[str]) -> None:
|
||||
|
||||
@@ -44,15 +44,21 @@ PROVIDER_DEFAULTS: dict[str, dict[str, int]] = {
|
||||
"o1-mini": {"context_window": 128000, "max_output_tokens": 65536},
|
||||
"claude-3-5-sonnet": {"context_window": 200000, "max_output_tokens": 8192},
|
||||
"deepseek-v4-flash": {"context_window": 64000, "max_output_tokens": 8192},
|
||||
"qwen-flash": {"context_window": 131072, "max_output_tokens": 32768},
|
||||
"qwen-plus": {"context_window": 131072, "max_output_tokens": 32768},
|
||||
"qwen-max": {"context_window": 32768, "max_output_tokens": 8192},
|
||||
"qwen-coder": {"context_window": 131072, "max_output_tokens": 32768},
|
||||
"default": {"context_window": 64000, "max_output_tokens": 16384},
|
||||
}
|
||||
# #endregion PROVIDER_DEFAULTS
|
||||
# Increased from 60 to 120 because SQL/dashboard text and JSON structure need more.
|
||||
# #region OUTPUT_PER_ROW_PER_LANG [TYPE Constant]
|
||||
# @ingroup Translate
|
||||
|
||||
# Increased from 60 to 120 because SQL/dashboard text and JSON structure need more.
|
||||
OUTPUT_PER_ROW_PER_LANG = 120
|
||||
# Increased from 120 to 200 because production logs show finish_reason=length
|
||||
# even with 120 tok/row/lang — long source texts produce long translations.
|
||||
# With 3 target languages, 200 tok/row/lang × 36 rows = 21600 output tokens
|
||||
# + JSON overhead + reasoning, well within 24K-32K typical output budgets.
|
||||
OUTPUT_PER_ROW_PER_LANG = 200
|
||||
# #endregion OUTPUT_PER_ROW_PER_LANG
|
||||
# #region JSON_OVERHEAD_PER_ROW [TYPE Constant]
|
||||
# @ingroup Translate
|
||||
@@ -109,7 +115,7 @@ INPUT_SAFETY_FACTOR = 0.75
|
||||
# #region OUTPUT_SAFETY_FACTOR [TYPE Constant]
|
||||
# @ingroup Translate
|
||||
# @BRIEF Multiplier applied to output budget when computing max rows per batch.
|
||||
OUTPUT_SAFETY_FACTOR = 0.70
|
||||
OUTPUT_SAFETY_FACTOR = 0.55
|
||||
# #endregion OUTPUT_SAFETY_FACTOR
|
||||
|
||||
# #region MIN_MAX_TOKENS [TYPE Constant]
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
# @POST Metrics are aggregated and returned; no side effects.
|
||||
# @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.
|
||||
# @COMPLEXITY 4
|
||||
|
||||
from typing import Any
|
||||
|
||||
@@ -31,7 +30,7 @@ class TranslationMetrics:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
#region get_job_metrics [TYPE Function]
|
||||
#region get_job_metrics [C:3] [TYPE Function]
|
||||
# @BRIEF Get aggregated metrics for a specific job.
|
||||
# @PRE job_id exists.
|
||||
# @POST Returns dict with metrics from events + latest snapshot.
|
||||
@@ -176,8 +175,8 @@ class TranslationMetrics:
|
||||
}
|
||||
# endregion get_job_metrics
|
||||
|
||||
# region get_all_metrics [TYPE Function]
|
||||
# @PURPOSE Get aggregated metrics for all jobs.
|
||||
# region get_all_metrics [C:2] [TYPE Function]
|
||||
# @BRIEF Get aggregated metrics for all jobs by iterating get_job_metrics.
|
||||
# @POST Returns list of per-job metrics.
|
||||
def get_all_metrics(self) -> list[dict[str, Any]]:
|
||||
with belief_scope("TranslationMetrics.get_all_metrics"):
|
||||
|
||||
@@ -27,6 +27,30 @@ from ...services.notifications.service import NotificationService
|
||||
from .events import TranslationEventLog
|
||||
|
||||
|
||||
# #region _ensure_aware [C:2] [TYPE Function] [SEMANTICS datetime,normalize,utc]
|
||||
# @ingroup Translate
|
||||
# @BRIEF Convert naive DB datetime to UTC-aware for safe arithmetic.
|
||||
# @PRE dt is either None or a datetime (naive or aware).
|
||||
# @POST Returns None if input is None; otherwise returns UTC-aware datetime.
|
||||
# @RATIONALE SQLAlchemy DateTime (without timezone=True) returns naive datetimes
|
||||
# even when stored as UTC. Subtracting naive from datetime.now(UTC)
|
||||
# raises TypeError. This normalises naive→aware assuming UTC storage.
|
||||
# @SIDE_EFFECT Pure function — no I/O or state mutation.
|
||||
def _ensure_aware(dt: datetime | None) -> datetime | None:
|
||||
"""Convert naive DB datetime to UTC-aware for safe arithmetic.
|
||||
|
||||
SQLAlchemy DateTime (without timezone=True) returns naive datetimes
|
||||
even when stored as UTC. This normalises them to aware datetimes
|
||||
so subtraction with datetime.now(UTC) does not raise TypeError.
|
||||
"""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=UTC)
|
||||
return dt
|
||||
# #endregion _ensure_aware
|
||||
|
||||
|
||||
# #region TranslationScheduler [C:4] [TYPE Class]
|
||||
# @defgroup Translate Module group.
|
||||
# @BRIEF CRUD for TranslationSchedule rows + APScheduler registration wrappers.
|
||||
@@ -250,7 +274,6 @@ class TranslationScheduler:
|
||||
# @POST Translation run created and executed if no concurrent run exists.
|
||||
# @SIDE_EFFECT DB writes; LLM calls; Superset API calls.
|
||||
# @RATIONALE New-key-only mode compares keys against most recent succeeded run; baseline_expired fallback does full.
|
||||
# @COMPLEXITY 4
|
||||
|
||||
def execute_scheduled_translation(
|
||||
schedule_id: str,
|
||||
@@ -294,22 +317,29 @@ def execute_scheduled_translation(
|
||||
.first()
|
||||
)
|
||||
if active_run:
|
||||
run_created = _ensure_aware(active_run.created_at)
|
||||
is_stale = (
|
||||
active_run.status == "PENDING"
|
||||
and active_run.created_at
|
||||
and (now - active_run.created_at) > stale_threshold
|
||||
and run_created is not None
|
||||
and (now - run_created) > stale_threshold
|
||||
)
|
||||
if is_stale:
|
||||
# Mark ALL stale PENDING runs as FAILED
|
||||
stale_threshold_dt = now - stale_threshold
|
||||
stale_runs = (
|
||||
db.query(TranslationRun)
|
||||
.filter(
|
||||
TranslationRun.job_id == job_id,
|
||||
TranslationRun.status == "PENDING",
|
||||
TranslationRun.created_at < (now - stale_threshold),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
# Filter in Python to avoid DB type mismatch on DateTime comparison
|
||||
stale_runs = [
|
||||
sr for sr in stale_runs
|
||||
if _ensure_aware(sr.created_at) is not None
|
||||
and _ensure_aware(sr.created_at) < stale_threshold_dt
|
||||
]
|
||||
for sr in stale_runs:
|
||||
sr.status = "FAILED"
|
||||
sr.error_message = "Stale: concurrency deadlock — marked by scheduled trigger"
|
||||
@@ -345,7 +375,11 @@ def execute_scheduled_translation(
|
||||
.first()
|
||||
)
|
||||
if most_recent:
|
||||
age = datetime.now(UTC) - most_recent.created_at
|
||||
recent_created = _ensure_aware(most_recent.created_at)
|
||||
if recent_created is not None:
|
||||
age = datetime.now(UTC) - recent_created
|
||||
else:
|
||||
age = timedelta(0)
|
||||
if age > timedelta(days=90):
|
||||
baseline_expired = True
|
||||
logger.reason("Baseline expired — full translation", {
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import TYPE_CHECKING
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..core.encryption import EncryptionManager
|
||||
from ..core.encryption import get_encryption_manager
|
||||
from ..core.logger import belief_scope, logger
|
||||
from ..models.llm import LLMProvider
|
||||
|
||||
@@ -72,7 +72,7 @@ class LLMProviderService:
|
||||
# @RELATION DEPENDS_ON ->[EncryptionManager]
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.encryption = EncryptionManager()
|
||||
self.encryption = get_encryption_manager()
|
||||
|
||||
# endregion LLMProviderService_init
|
||||
|
||||
|
||||
@@ -168,4 +168,50 @@ class TestEnsureEncryptionKey:
|
||||
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY is not set"):
|
||||
ensure_encryption_key(env_file)
|
||||
# #endregion test_ensure_key_file_without_key_raises
|
||||
|
||||
|
||||
# ── get_encryption_manager singleton ────────────────────────────────────────
|
||||
|
||||
class TestGetEncryptionManager:
|
||||
"""get_encryption_manager() — process-wide singleton."""
|
||||
|
||||
# #region test_singleton_returns_same_instance [C:2] [TYPE Function] [SEMANTICS test,singleton]
|
||||
# @BRIEF Two calls to get_encryption_manager return the identical object.
|
||||
def test_singleton_returns_same_instance(self, monkeypatch):
|
||||
from src.core.encryption import get_encryption_manager
|
||||
|
||||
monkeypatch.setenv("ENCRYPTION_KEY", _VALID_FERNET_KEY)
|
||||
# Reset module-level singleton
|
||||
import src.core.encryption as enc_mod
|
||||
enc_mod._encryption_manager = None
|
||||
|
||||
mgr1 = get_encryption_manager()
|
||||
mgr2 = get_encryption_manager()
|
||||
assert mgr1 is mgr2
|
||||
# #endregion test_singleton_returns_same_instance
|
||||
|
||||
# #region test_singleton_after_exception [C:2] [TYPE Function] [SEMANTICS test,singleton,recovery]
|
||||
# @BRIEF If first creation fails (no ENCRYPTION_KEY), singleton resets
|
||||
# properly — subsequent call with valid key succeeds and is cached.
|
||||
def test_singleton_after_exception(self, monkeypatch):
|
||||
from src.core.encryption import get_encryption_manager
|
||||
|
||||
# Ensure no key — first call raises
|
||||
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
|
||||
import src.core.encryption as enc_mod
|
||||
enc_mod._encryption_manager = None
|
||||
|
||||
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY must be set"):
|
||||
get_encryption_manager()
|
||||
# Singleton remains None after exception
|
||||
assert enc_mod._encryption_manager is None
|
||||
|
||||
# Now set valid key — second call succeeds
|
||||
monkeypatch.setenv("ENCRYPTION_KEY", _VALID_FERNET_KEY)
|
||||
mgr = get_encryption_manager()
|
||||
assert mgr is not None
|
||||
# And subsequent call returns same instance
|
||||
assert get_encryption_manager() is mgr
|
||||
# #endregion test_singleton_after_exception
|
||||
|
||||
# #endregion Test.Encryption
|
||||
|
||||
@@ -400,6 +400,28 @@ class TestExecuteInsertSql:
|
||||
await _execute_insert_sql(executor, "INSERT ...", "batch-1", 5)
|
||||
executor.execute_and_poll.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_db_success(self):
|
||||
"""DbExecutor path: successful execution."""
|
||||
from src.core.db_executor import DbExecutor, DbExecutionResult
|
||||
executor = MagicMock(spec=DbExecutor)
|
||||
executor.execute_sql = AsyncMock(return_value=DbExecutionResult(
|
||||
success=True, rows_affected=5, execution_time_ms=12.0
|
||||
))
|
||||
await _execute_insert_sql(executor, "INSERT ...", "batch-1", 5, connection_id="conn-1")
|
||||
executor.execute_sql.assert_called_once_with("conn-1", "INSERT ...")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_db_failure(self):
|
||||
"""DbExecutor path: failed execution with error."""
|
||||
from src.core.db_executor import DbExecutor, DbExecutionResult
|
||||
executor = MagicMock(spec=DbExecutor)
|
||||
executor.execute_sql = AsyncMock(return_value=DbExecutionResult(
|
||||
success=False, error="Connection refused"
|
||||
))
|
||||
await _execute_insert_sql(executor, "INSERT ...", "batch-1", 5, connection_id="conn-1")
|
||||
executor.execute_sql.assert_called_once_with("conn-1", "INSERT ...")
|
||||
|
||||
|
||||
class TestResolveInsertBackend:
|
||||
"""_resolve_insert_backend — resolve backend dialect and executor."""
|
||||
@@ -421,13 +443,14 @@ class TestResolveInsertBackend:
|
||||
mock_exec.resolve_database_id = AsyncMock()
|
||||
mock_exec_cls.return_value = mock_exec
|
||||
|
||||
dialect, executor = await _resolve_insert_backend(config_manager, job, "batch-1")
|
||||
dialect, executor, connection_id = await _resolve_insert_backend(config_manager, job, "batch-1")
|
||||
assert dialect == "postgresql"
|
||||
assert executor is not None
|
||||
assert connection_id is None # sqllab path has no connection_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failure_returns_none(self):
|
||||
"""Exception in resolve returns (None, None)."""
|
||||
"""Exception in resolve returns (None, None, None)."""
|
||||
config_manager = MagicMock()
|
||||
job = MagicMock(spec=TranslationJob)
|
||||
job.environment_id = None
|
||||
@@ -435,9 +458,10 @@ class TestResolveInsertBackend:
|
||||
|
||||
with patch("src.plugins.translate._batch_insert.SupersetSqlLabExecutor",
|
||||
side_effect=Exception("Failed")):
|
||||
dialect, executor = await _resolve_insert_backend(config_manager, job, "batch-1")
|
||||
dialect, executor, connection_id = await _resolve_insert_backend(config_manager, job, "batch-1")
|
||||
assert dialect is None
|
||||
assert executor is None
|
||||
assert connection_id is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_dialect(self):
|
||||
@@ -456,8 +480,59 @@ class TestResolveInsertBackend:
|
||||
mock_exec.resolve_database_id = AsyncMock()
|
||||
mock_exec_cls.return_value = mock_exec
|
||||
|
||||
dialect, executor = await _resolve_insert_backend(config_manager, job, "batch-1")
|
||||
dialect, _, connection_id = await _resolve_insert_backend(config_manager, job, "batch-1")
|
||||
assert dialect == "mysql"
|
||||
assert connection_id is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_db_path(self):
|
||||
"""direct_db path returns DbExecutor with connection_id."""
|
||||
config_manager = MagicMock()
|
||||
job = MagicMock(spec=TranslationJob)
|
||||
job.insert_method = "direct_db"
|
||||
job.connection_id = "clickhouse-conn-1"
|
||||
|
||||
with patch("src.plugins.translate._batch_insert.ConnectionService") as mock_cs_cls:
|
||||
mock_cs = MagicMock()
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.dialect = "clickhouse"
|
||||
mock_conn.name = "Test ClickHouse"
|
||||
mock_cs.get_connection.return_value = mock_conn
|
||||
mock_cs_cls.return_value = mock_cs
|
||||
|
||||
dialect, executor, connection_id = await _resolve_insert_backend(config_manager, job, "batch-1")
|
||||
assert dialect == "clickhouse"
|
||||
assert connection_id == "clickhouse-conn-1"
|
||||
# Should return DbExecutor, not SupersetSqlLabExecutor
|
||||
from src.core.db_executor import DbExecutor
|
||||
assert isinstance(executor, DbExecutor)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_db_missing_conn_raises(self):
|
||||
"""When direct_db connection not found, raises ValueError (no fallback)."""
|
||||
config_manager = MagicMock()
|
||||
job = MagicMock(spec=TranslationJob)
|
||||
job.insert_method = "direct_db"
|
||||
job.connection_id = "missing-conn"
|
||||
|
||||
with patch("src.plugins.translate._batch_insert.ConnectionService") as mock_cs_cls:
|
||||
mock_cs = MagicMock()
|
||||
mock_cs.get_connection.return_value = None
|
||||
mock_cs_cls.return_value = mock_cs
|
||||
|
||||
with pytest.raises(ValueError, match="connection_id='missing-conn' not found"):
|
||||
await _resolve_insert_backend(config_manager, job, "batch-1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_db_no_connection_id_raises(self):
|
||||
"""When direct_db is set but connection_id is None, raises ValueError (no fallback)."""
|
||||
config_manager = MagicMock()
|
||||
job = MagicMock(spec=TranslationJob)
|
||||
job.insert_method = "direct_db"
|
||||
job.connection_id = None
|
||||
|
||||
with pytest.raises(ValueError, match="connection_id is missing"):
|
||||
await _resolve_insert_backend(config_manager, job, "batch-1")
|
||||
|
||||
|
||||
class TestInsertBatchToTarget:
|
||||
@@ -499,7 +574,7 @@ class TestInsertBatchToTarget:
|
||||
|
||||
with patch("src.plugins.translate._batch_insert._fetch_batch_records", return_value=[rec]):
|
||||
with patch("src.plugins.translate._batch_insert._resolve_insert_backend",
|
||||
return_value=("postgresql", AsyncMock())):
|
||||
return_value=("postgresql", AsyncMock(), None)):
|
||||
with patch("src.plugins.translate._batch_insert.SQLGenerator.generate_batch",
|
||||
return_value=[("INSERT INTO ...", 1)]):
|
||||
with patch("src.plugins.translate._batch_insert._execute_insert_sql",
|
||||
@@ -530,7 +605,7 @@ class TestInsertBatchToTarget:
|
||||
with patch("src.plugins.translate._batch_insert._build_insert_rows",
|
||||
return_value=[{"text": "bonjour"}]):
|
||||
with patch("src.plugins.translate._batch_insert._resolve_insert_backend",
|
||||
return_value=(None, None)):
|
||||
return_value=(None, None, None)):
|
||||
result = await insert_batch_to_target(
|
||||
db_session, config_manager, job, "batch-1", "run-1"
|
||||
)
|
||||
|
||||
@@ -163,7 +163,7 @@ class TestInsertBatchToTargetEdgeCases:
|
||||
with patch("src.plugins.translate._batch_insert._build_insert_rows",
|
||||
return_value=[{"text": "bonjour"}]):
|
||||
with patch("src.plugins.translate._batch_insert._resolve_insert_backend",
|
||||
return_value=("postgresql", AsyncMock())):
|
||||
return_value=("postgresql", AsyncMock(), None)):
|
||||
with patch("src.plugins.translate._batch_insert.SQLGenerator.generate_batch",
|
||||
return_value=[("INSERT INTO ...", 1)]):
|
||||
with patch("src.plugins.translate._batch_insert._execute_insert_sql",
|
||||
@@ -208,7 +208,7 @@ class TestInsertBatchToTargetEdgeCases:
|
||||
with patch("src.plugins.translate._batch_insert._build_insert_rows",
|
||||
return_value=[{"text": "bonjour", "is_original": 1}]):
|
||||
with patch("src.plugins.translate._batch_insert._resolve_insert_backend",
|
||||
return_value=("postgresql", mock_executor)):
|
||||
return_value=("postgresql", mock_executor, None)):
|
||||
with patch("src.plugins.translate._batch_insert.SQLGenerator.generate_batch",
|
||||
return_value=[(None, 0), ("INSERT INTO target ...", 1)]):
|
||||
result = await insert_batch_to_target(
|
||||
|
||||
@@ -257,4 +257,61 @@ class TestDeleteEdgeCases:
|
||||
svc = ConnectionService(mock_cm)
|
||||
result = svc.update_connection("c1", {"name": "My PG"})
|
||||
assert result["name"] == "My PG"
|
||||
|
||||
|
||||
class TestConnectionTimeout:
|
||||
"""Connection test timeout behavior — asyncio.TimeoutError handling."""
|
||||
|
||||
# #region test_timeout_error_response [C:2] [TYPE Function] [SEMANTICS test,connection,timeout,error]
|
||||
# @BRIEF When _test_postgresql hangs, asyncio.TimeoutError is caught and
|
||||
# returns {success: false, error: "Connection test timed out after 15s"}.
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_error_response(self, mock_cm):
|
||||
from src.core.config_models import DatabaseConnection
|
||||
|
||||
conn = DatabaseConnection(id="c1", name="PG", host="h", port=5432,
|
||||
database="d", username="u", password="p",
|
||||
dialect="postgresql")
|
||||
svc = ConnectionService(mock_cm)
|
||||
svc.TEST_CONNECTION_TIMEOUT_SEC = 0.1 # fast timeout for test
|
||||
|
||||
async def _hang(*_):
|
||||
import asyncio
|
||||
await asyncio.sleep(3600) # never returns
|
||||
|
||||
with patch.object(svc, 'get_connection', return_value=conn), \
|
||||
patch.object(svc, '_test_postgresql', side_effect=_hang):
|
||||
result = await svc.test_connection("c1")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Connection test timed out" in result["error"]
|
||||
assert "15" not in result["error"] # we overrode to 0.1
|
||||
assert "after" in result["error"]
|
||||
# #endregion test_timeout_error_response
|
||||
|
||||
# #region test_timeout_still_records_latency [C:2] [TYPE Function] [SEMANTICS test,connection,timeout,latency]
|
||||
# @BRIEF Even on timeout, latency_ms field is populated with elapsed time.
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_still_records_latency(self, mock_cm):
|
||||
from src.core.config_models import DatabaseConnection
|
||||
|
||||
conn = DatabaseConnection(id="c1", name="PG", host="h", port=5432,
|
||||
database="d", username="u", password="p",
|
||||
dialect="postgresql")
|
||||
svc = ConnectionService(mock_cm)
|
||||
svc.TEST_CONNECTION_TIMEOUT_SEC = 0.05 # very fast timeout
|
||||
|
||||
async def _hang(*_):
|
||||
import asyncio
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
with patch.object(svc, 'get_connection', return_value=conn), \
|
||||
patch.object(svc, '_test_postgresql', side_effect=_hang):
|
||||
result = await svc.test_connection("c1")
|
||||
|
||||
assert "latency_ms" in result
|
||||
assert isinstance(result["latency_ms"], int)
|
||||
assert result["latency_ms"] >= 0 # should be a non-negative elapsed time
|
||||
# #endregion test_timeout_still_records_latency
|
||||
|
||||
# #endregion Test.Core.ConnectionService.Edge
|
||||
|
||||
@@ -7,8 +7,8 @@ import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
# #region test_trace_middleware [C:2] [TYPE Function]
|
||||
@@ -93,12 +93,155 @@ class TestTraceContextMiddleware:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forwards_to_app(self, middleware, mock_app):
|
||||
"""After seeding, the ASGI app is called."""
|
||||
"""After seeding, the ASGI app is called with scope, receive, and a wrapped send."""
|
||||
scope = {"type": "http", "headers": []}
|
||||
receive = MagicMock()
|
||||
send = MagicMock()
|
||||
await middleware(scope, receive, send)
|
||||
mock_app.assert_called_once_with(scope, receive, send)
|
||||
# The middleware wraps send in send_with_trace, so verify scope+receive match
|
||||
mock_app.assert_called_once()
|
||||
call_args = mock_app.call_args
|
||||
assert call_args[0][0] == scope
|
||||
assert call_args[0][1] == receive
|
||||
# call_args[0][2] is send_with_trace — a wrapper function, not the original send
|
||||
assert callable(call_args[0][2])
|
||||
# #endregion test_trace_middleware
|
||||
|
||||
|
||||
|
||||
# #region TestTraceResponseHeaders [C:2] [TYPE Class] [SEMANTICS test,middleware,trace,header]
|
||||
# @BRIEF Verify X-Trace-Id response header injection — new, preserved, passthrough.
|
||||
class TestTraceResponseHeaders:
|
||||
"""TraceContextMiddleware response header injection."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app(self):
|
||||
return AsyncMock()
|
||||
|
||||
@pytest.fixture
|
||||
def middleware(self, mock_app):
|
||||
from src.core.middleware.trace import TraceContextMiddleware
|
||||
return TraceContextMiddleware(mock_app)
|
||||
|
||||
# #region test_sets_x_trace_id_header_on_response [C:2] [TYPE Function] [SEMANTICS test,trace,header]
|
||||
# @BRIEF HTTP response receives x-trace-id header with a valid UUID4 value.
|
||||
@pytest.mark.asyncio
|
||||
async def test_sets_x_trace_id_header_on_response(self, middleware, mock_app):
|
||||
"""The middleware injects x-trace-id into http.response.start headers."""
|
||||
import uuid
|
||||
|
||||
scope = {"type": "http", "headers": []}
|
||||
receive = MagicMock()
|
||||
send = AsyncMock()
|
||||
|
||||
# The inner app receives send_with_trace as its 3rd positional arg.
|
||||
# Use that wrapped send to exercise header injection.
|
||||
async def _inner_app(*args):
|
||||
_send = args[2] # send_with_trace from middleware
|
||||
await _send({
|
||||
"type": "http.response.start",
|
||||
"status": 200,
|
||||
"headers": [(b"content-type", b"text/plain")],
|
||||
})
|
||||
await _send({
|
||||
"type": "http.response.body",
|
||||
"body": b"OK",
|
||||
})
|
||||
|
||||
mock_app.side_effect = _inner_app
|
||||
|
||||
await middleware(scope, receive, send)
|
||||
|
||||
# Collect send calls — find http.response.start
|
||||
send_calls = [c for c in send.call_args_list
|
||||
if c.args[0]["type"] == "http.response.start"]
|
||||
assert len(send_calls) >= 1, "No http.response.start send call"
|
||||
|
||||
headers = dict(send_calls[0].args[0].get("headers", []))
|
||||
assert b"x-trace-id" in headers, "Missing x-trace-id header"
|
||||
|
||||
trace_id = headers[b"x-trace-id"].decode("ascii")
|
||||
# Valid UUID4
|
||||
parsed = uuid.UUID(hex=trace_id)
|
||||
assert parsed.version == 4, f"Trace ID is not UUID4: got version {parsed.version}"
|
||||
# #endregion test_sets_x_trace_id_header_on_response
|
||||
|
||||
# #region test_preserves_incoming_trace_id [C:2] [TYPE Function] [SEMANTICS test,trace,header,preserve]
|
||||
# @BRIEF When request carries X-Trace-ID, the response header uses the same value.
|
||||
@pytest.mark.asyncio
|
||||
async def test_preserves_incoming_trace_id(self, middleware, mock_app):
|
||||
"""Incoming X-Trace-ID is echoed back in the response header."""
|
||||
incoming_id = "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"headers": [(b"x-trace-id", incoming_id.encode())],
|
||||
}
|
||||
receive = MagicMock()
|
||||
send = AsyncMock()
|
||||
|
||||
async def _inner_app(*args):
|
||||
_send = args[2] # send_with_trace from middleware
|
||||
await _send({
|
||||
"type": "http.response.start",
|
||||
"status": 200,
|
||||
"headers": [],
|
||||
})
|
||||
await _send({
|
||||
"type": "http.response.body",
|
||||
"body": b"OK",
|
||||
})
|
||||
|
||||
mock_app.side_effect = _inner_app
|
||||
|
||||
await middleware(scope, receive, send)
|
||||
|
||||
send_calls = [c for c in send.call_args_list
|
||||
if c.args[0]["type"] == "http.response.start"]
|
||||
assert len(send_calls) >= 1
|
||||
|
||||
headers = dict(send_calls[0].args[0].get("headers", []))
|
||||
assert b"x-trace-id" in headers
|
||||
|
||||
response_trace_id = headers[b"x-trace-id"].decode("ascii")
|
||||
assert response_trace_id == incoming_id, (
|
||||
f"Response trace ID {response_trace_id} != incoming {incoming_id}"
|
||||
)
|
||||
# #endregion test_preserves_incoming_trace_id
|
||||
|
||||
# #region test_non_http_scope_passthrough [C:2] [TYPE Function] [SEMANTICS test,trace,websocket,passthrough]
|
||||
# @BRIEF Non-HTTP scopes (websocket, lifespan) do not inject X-Trace-Id header.
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_http_scope_passthrough(self, middleware, mock_app):
|
||||
"""Websocket scope passes through without header injection."""
|
||||
scope = {"type": "websocket", "headers": []}
|
||||
receive = MagicMock()
|
||||
send = MagicMock()
|
||||
|
||||
async def _inner_app(scope, receive, send):
|
||||
# Ensure the app was called with the original send, not send_with_trace
|
||||
pass
|
||||
|
||||
mock_app.side_effect = _inner_app
|
||||
|
||||
await middleware(scope, receive, send)
|
||||
|
||||
# For non-http, the middleware calls self.app(scope, receive, send)
|
||||
# (the original send — no wrapper). Verify original send was called.
|
||||
mock_app.assert_called_once_with(scope, receive, send)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifespan_scope_passthrough(self, middleware, mock_app):
|
||||
"""Lifespan scope passes through without header injection."""
|
||||
scope = {"type": "lifespan", "headers": []}
|
||||
receive = MagicMock()
|
||||
send = MagicMock()
|
||||
|
||||
await middleware(scope, receive, send)
|
||||
|
||||
# The middleware returns early for non-http, calling self.app directly
|
||||
mock_app.assert_called_once_with(scope, receive, send)
|
||||
# #endregion test_non_http_scope_passthrough
|
||||
|
||||
# #endregion TestTraceResponseHeaders
|
||||
# #endregion Test.TraceContextMiddleware
|
||||
|
||||
80
backend/tests/test_scheduler_ensure_aware.py
Normal file
80
backend/tests/test_scheduler_ensure_aware.py
Normal file
@@ -0,0 +1,80 @@
|
||||
# #region Test.Scheduler.EnsureAware [C:2] [TYPE Module] [SEMANTICS test,scheduler,datetime,utc]
|
||||
# @BRIEF Verify _ensure_aware contract — naive→UTC conversion, aware passthrough, None passthrough.
|
||||
# @RELATION BINDS_TO -> [TranslationScheduler._ensure_aware]
|
||||
# @TEST_EDGE naive_to_aware -> Naive datetime gets UTC tzinfo
|
||||
# @TEST_EDGE already_aware -> Aware datetime returned unchanged
|
||||
# @TEST_EDGE none_passthrough -> None returns None
|
||||
# @TEST_EDGE subtraction_works -> Conversion enables subtraction with datetime.now(UTC)
|
||||
#
|
||||
# @RATIONALE SQLAlchemy DateTime (without timezone=True) returns naive datetimes
|
||||
# even when stored as UTC. This module verifies the normalization
|
||||
# helper used throughout execute_scheduled_translation for safe
|
||||
# datetime arithmetic.
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
|
||||
# #region TestEnsureAware [C:2] [TYPE Class] [SEMANTICS test,scheduler,datetime]
|
||||
# @BRIEF Tests for _ensure_aware() — naive-to-UTC conversion and edge cases.
|
||||
class TestEnsureAware:
|
||||
"""_ensure_aware — convert naive DB datetime to UTC-aware for safe arithmetic."""
|
||||
|
||||
# #region test_naive_to_aware [C:2] [TYPE Function] [SEMANTICS test,datetime,naive]
|
||||
# @BRIEF Naive datetime gets UTC tzinfo attached.
|
||||
def test_naive_to_aware(self):
|
||||
from src.plugins.translate.scheduler import _ensure_aware
|
||||
|
||||
naive = datetime(2024, 6, 15, 10, 30, 0)
|
||||
result = _ensure_aware(naive)
|
||||
|
||||
assert result.tzinfo is not None
|
||||
assert result.tzinfo == UTC
|
||||
assert result == naive.replace(tzinfo=UTC)
|
||||
assert result.hour == 10 # no offset shift
|
||||
# #endregion test_naive_to_aware
|
||||
|
||||
# #region test_already_aware_passthrough [C:2] [TYPE Function] [SEMANTICS test,datetime,aware]
|
||||
# @BRIEF Already UTC-aware datetime returned unchanged (same object).
|
||||
def test_already_aware_passthrough(self):
|
||||
from src.plugins.translate.scheduler import _ensure_aware
|
||||
|
||||
aware = datetime(2024, 6, 15, 10, 30, 0, tzinfo=UTC)
|
||||
result = _ensure_aware(aware)
|
||||
|
||||
assert result is aware # same object identity
|
||||
assert result.tzinfo == UTC
|
||||
# #endregion test_already_aware_passthrough
|
||||
|
||||
# #region test_none_returns_none [C:2] [TYPE Function] [SEMANTICS test,datetime,none]
|
||||
# @BRIEF None input returns None.
|
||||
def test_none_returns_none(self):
|
||||
from src.plugins.translate.scheduler import _ensure_aware
|
||||
|
||||
result = _ensure_aware(None)
|
||||
assert result is None
|
||||
# #endregion test_none_returns_none
|
||||
|
||||
# #region test_subtraction_works [C:2] [TYPE Function] [SEMANTICS test,datetime,arithmetic]
|
||||
# @BRIEF Naive→aware conversion enables subtraction with datetime.now(UTC)
|
||||
# without TypeError (the original problem this function solves).
|
||||
def test_subtraction_works(self):
|
||||
from src.plugins.translate.scheduler import _ensure_aware
|
||||
|
||||
naive = datetime(2024, 6, 15, 10, 0, 0)
|
||||
aware = _ensure_aware(naive)
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# This would raise TypeError without _ensure_aware:
|
||||
# TypeError: can't subtract offset-naive and offset-aware datetimes
|
||||
elapsed = now - aware
|
||||
assert isinstance(elapsed, timedelta)
|
||||
assert elapsed.total_seconds() > 0 # always positive vs past date
|
||||
# #endregion test_subtraction_works
|
||||
|
||||
# #endregion TestEnsureAware
|
||||
# #endregion Test.Scheduler.EnsureAware
|
||||
Reference in New Issue
Block a user