semantics: complete DEF-to-region migration, fix regressions
- Convert legacy [DEF🆔Type] anchors to #region/#endregion across 329 files
- Reinstate _normalize_timestamp_value in sql_generator.py
- Fix MarkerLogger→logger migration in events.py (molecular CoT markers)
- Fix dataset_review orchestrator dependencies (_build_execution_snapshot)
- Fix config_manager stale-record deletion (moved to save path only)
- Add 77 missing [/DEF:] closers in 5 unbalanced test files
- Update assistant_chat.integration.test.js for #region format
- Apply molecular-cot-logging markers (REASON/REFLECT/EXPLORE) via logger.* methods
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
# #region test_encryption_manager [C:3] [TYPE Module] [SEMANTICS encryption, security, fernet, api-keys, tests]
|
||||
# @BRIEF Unit tests for EncryptionManager encrypt/decrypt functionality.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT Encrypt+decrypt roundtrip always returns original plaintext.
|
||||
# @RELATION BELONGS_TO -> [SrcRoot]
|
||||
# [DEF:test_encryption_manager:Module]
|
||||
# @RELATION: BELONGS_TO -> SrcRoot
|
||||
# @COMPLEXITY: 3
|
||||
# @SEMANTICS: encryption, security, fernet, api-keys, tests
|
||||
# @PURPOSE: Unit tests for EncryptionManager encrypt/decrypt functionality.
|
||||
# @LAYER: Domain
|
||||
# @INVARIANT: Encrypt+decrypt roundtrip always returns original plaintext.
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -13,11 +15,11 @@ from unittest.mock import patch
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
|
||||
# #region TestEncryptionManager [TYPE Class]
|
||||
# @BRIEF Validate EncryptionManager encrypt/decrypt roundtrip, uniqueness, and error handling.
|
||||
# @PRE cryptography package installed.
|
||||
# @POST All encrypt/decrypt invariants verified.
|
||||
# @RELATION BINDS_TO -> [test_encryption_manager]
|
||||
# [DEF:TestEncryptionManager:Class]
|
||||
# @RELATION: BINDS_TO -> test_encryption_manager
|
||||
# @PURPOSE: Validate EncryptionManager encrypt/decrypt roundtrip, uniqueness, and error handling.
|
||||
# @PRE: cryptography package installed.
|
||||
# @POST: All encrypt/decrypt invariants verified.
|
||||
class TestEncryptionManager:
|
||||
"""Tests for the EncryptionManager class."""
|
||||
|
||||
@@ -39,10 +41,10 @@ class TestEncryptionManager:
|
||||
|
||||
return EncryptionManager()
|
||||
|
||||
# #region test_encrypt_decrypt_roundtrip [TYPE Function]
|
||||
# @BRIEF Encrypt then decrypt returns original plaintext.
|
||||
# @PRE Valid plaintext string.
|
||||
# @POST Decrypted output equals original input.
|
||||
# [DEF:test_encrypt_decrypt_roundtrip:Function]
|
||||
# @PURPOSE: Encrypt then decrypt returns original plaintext.
|
||||
# @PRE: Valid plaintext string.
|
||||
# @POST: Decrypted output equals original input.
|
||||
def test_encrypt_decrypt_roundtrip(self):
|
||||
mgr = self._make_manager()
|
||||
original = "my-secret-api-key-12345"
|
||||
@@ -50,69 +52,69 @@ class TestEncryptionManager:
|
||||
assert encrypted != original
|
||||
decrypted = mgr.decrypt(encrypted)
|
||||
assert decrypted == original
|
||||
# #endregion test_encrypt_decrypt_roundtrip
|
||||
# [/DEF:test_encrypt_decrypt_roundtrip:Function]
|
||||
|
||||
# #region test_encrypt_produces_different_output [TYPE Function]
|
||||
# @BRIEF Same plaintext produces different ciphertext (Fernet uses random IV).
|
||||
# @PRE Two encrypt calls with same input.
|
||||
# @POST Ciphertexts differ but both decrypt to same value.
|
||||
# [DEF:test_encrypt_produces_different_output:Function]
|
||||
# @PURPOSE: Same plaintext produces different ciphertext (Fernet uses random IV).
|
||||
# @PRE: Two encrypt calls with same input.
|
||||
# @POST: Ciphertexts differ but both decrypt to same value.
|
||||
def test_encrypt_produces_different_output(self):
|
||||
mgr = self._make_manager()
|
||||
ct1 = mgr.encrypt("same-key")
|
||||
ct2 = mgr.encrypt("same-key")
|
||||
assert ct1 != ct2
|
||||
assert mgr.decrypt(ct1) == mgr.decrypt(ct2) == "same-key"
|
||||
# #endregion test_encrypt_produces_different_output
|
||||
# [/DEF:test_encrypt_produces_different_output:Function]
|
||||
|
||||
# #region test_different_inputs_yield_different_ciphertext [TYPE Function]
|
||||
# @BRIEF Different inputs produce different ciphertexts.
|
||||
# @PRE Two different plaintext values.
|
||||
# @POST Encrypted outputs differ.
|
||||
# [DEF:test_different_inputs_yield_different_ciphertext:Function]
|
||||
# @PURPOSE: Different inputs produce different ciphertexts.
|
||||
# @PRE: Two different plaintext values.
|
||||
# @POST: Encrypted outputs differ.
|
||||
def test_different_inputs_yield_different_ciphertext(self):
|
||||
mgr = self._make_manager()
|
||||
ct1 = mgr.encrypt("key-one")
|
||||
ct2 = mgr.encrypt("key-two")
|
||||
assert ct1 != ct2
|
||||
# #endregion test_different_inputs_yield_different_ciphertext
|
||||
# [/DEF:test_different_inputs_yield_different_ciphertext:Function]
|
||||
|
||||
# #region test_decrypt_invalid_data_raises [TYPE Function]
|
||||
# @BRIEF Decrypting invalid data raises InvalidToken.
|
||||
# @PRE Invalid ciphertext string.
|
||||
# @POST Exception raised.
|
||||
# [DEF:test_decrypt_invalid_data_raises:Function]
|
||||
# @PURPOSE: Decrypting invalid data raises InvalidToken.
|
||||
# @PRE: Invalid ciphertext string.
|
||||
# @POST: Exception raised.
|
||||
def test_decrypt_invalid_data_raises(self):
|
||||
mgr = self._make_manager()
|
||||
with pytest.raises(Exception):
|
||||
mgr.decrypt("not-a-valid-fernet-token")
|
||||
# #endregion test_decrypt_invalid_data_raises
|
||||
# [/DEF:test_decrypt_invalid_data_raises:Function]
|
||||
|
||||
# #region test_encrypt_empty_string [TYPE Function]
|
||||
# @BRIEF Encrypting and decrypting an empty string works.
|
||||
# @PRE Empty string input.
|
||||
# @POST Decrypted output equals empty string.
|
||||
# [DEF:test_encrypt_empty_string:Function]
|
||||
# @PURPOSE: Encrypting and decrypting an empty string works.
|
||||
# @PRE: Empty string input.
|
||||
# @POST: Decrypted output equals empty string.
|
||||
def test_encrypt_empty_string(self):
|
||||
mgr = self._make_manager()
|
||||
encrypted = mgr.encrypt("")
|
||||
assert encrypted
|
||||
decrypted = mgr.decrypt(encrypted)
|
||||
assert decrypted == ""
|
||||
# #endregion test_encrypt_empty_string
|
||||
# [/DEF:test_encrypt_empty_string:Function]
|
||||
|
||||
# #region test_missing_key_fails_fast [TYPE Function]
|
||||
# @BRIEF Missing ENCRYPTION_KEY must abort initialization instead of using a fallback secret.
|
||||
# @PRE ENCRYPTION_KEY is unset.
|
||||
# @POST RuntimeError raised during EncryptionManager construction.
|
||||
# [DEF:test_missing_key_fails_fast:Function]
|
||||
# @PURPOSE: Missing ENCRYPTION_KEY must abort initialization instead of using a fallback secret.
|
||||
# @PRE: ENCRYPTION_KEY is unset.
|
||||
# @POST: RuntimeError raised during EncryptionManager construction.
|
||||
def test_missing_key_fails_fast(self):
|
||||
from src.services.llm_provider import EncryptionManager
|
||||
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY must be set"):
|
||||
EncryptionManager()
|
||||
# #endregion test_missing_key_fails_fast
|
||||
# [/DEF:test_missing_key_fails_fast:Function]
|
||||
|
||||
# #region test_custom_key_roundtrip [TYPE Function]
|
||||
# @BRIEF Custom Fernet key produces valid roundtrip.
|
||||
# @PRE Generated Fernet key.
|
||||
# @POST Encrypt/decrypt with custom key succeeds.
|
||||
# [DEF:test_custom_key_roundtrip:Function]
|
||||
# @PURPOSE: Custom Fernet key produces valid roundtrip.
|
||||
# @PRE: Generated Fernet key.
|
||||
# @POST: Encrypt/decrypt with custom key succeeds.
|
||||
def test_custom_key_roundtrip(self):
|
||||
custom_key = Fernet.generate_key()
|
||||
fernet = Fernet(custom_key)
|
||||
@@ -130,7 +132,7 @@ class TestEncryptionManager:
|
||||
encrypted = mgr.encrypt("test-with-custom-key")
|
||||
decrypted = mgr.decrypt(encrypted)
|
||||
assert decrypted == "test-with-custom-key"
|
||||
# #endregion test_custom_key_roundtrip
|
||||
# [/DEF:test_custom_key_roundtrip:Function]
|
||||
|
||||
# #endregion TestEncryptionManager
|
||||
# #endregion test_encryption_manager
|
||||
# [/DEF:TestEncryptionManager:Class]
|
||||
# [/DEF:test_encryption_manager:Module]
|
||||
|
||||
@@ -4,9 +4,10 @@ from unittest.mock import MagicMock, patch
|
||||
from src.services.health_service import HealthService
|
||||
from src.models.llm import ValidationRecord
|
||||
|
||||
# #region test_health_service [C:3] [TYPE Module]
|
||||
# @BRIEF Unit tests for HealthService aggregation logic.
|
||||
# @RELATION VERIFIES -> [src.services.health_service.HealthService]
|
||||
# [DEF:test_health_service:Module]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Unit tests for HealthService aggregation logic.
|
||||
# @RELATION: VERIFIES ->[src.services.health_service.HealthService]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -161,9 +162,9 @@ async def test_get_health_summary_reuses_dashboard_metadata_cache_across_service
|
||||
HealthService._dashboard_summary_cache.clear()
|
||||
|
||||
|
||||
# #region test_delete_validation_report_deletes_dashboard_scope_and_linked_tasks [TYPE Function]
|
||||
# @BRIEF Verify that deleting a validation report also removes dashboard scope and linked tasks.
|
||||
# @RELATION BINDS_TO -> [test_health_service]
|
||||
# [DEF:test_delete_validation_report_deletes_dashboard_scope_and_linked_tasks:Function]
|
||||
# @RELATION: BINDS_TO ->[test_health_service]
|
||||
# @PURPOSE: Verify that deleting a validation report also removes dashboard scope and linked tasks.
|
||||
def test_delete_validation_report_deletes_dashboard_scope_and_linked_tasks():
|
||||
db = MagicMock()
|
||||
config_manager = MagicMock()
|
||||
@@ -234,12 +235,12 @@ def test_delete_validation_report_deletes_dashboard_scope_and_linked_tasks():
|
||||
assert "task-3" in task_manager.tasks
|
||||
|
||||
|
||||
# #endregion test_delete_validation_report_deletes_dashboard_scope_and_linked_tasks
|
||||
# [/DEF:test_delete_validation_report_deletes_dashboard_scope_and_linked_tasks:Function]
|
||||
|
||||
|
||||
# #region test_delete_validation_report_returns_false_for_unknown_record [TYPE Function]
|
||||
# @BRIEF Verify delete returns False when validation record does not exist.
|
||||
# @RELATION BINDS_TO -> [test_health_service]
|
||||
# [DEF:test_delete_validation_report_returns_false_for_unknown_record:Function]
|
||||
# @RELATION: BINDS_TO ->[test_health_service]
|
||||
# @PURPOSE: Verify delete returns False when validation record does not exist.
|
||||
def test_delete_validation_report_returns_false_for_unknown_record():
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = None
|
||||
@@ -249,12 +250,12 @@ def test_delete_validation_report_returns_false_for_unknown_record():
|
||||
assert service.delete_validation_report("missing") is False
|
||||
|
||||
|
||||
# #endregion test_delete_validation_report_returns_false_for_unknown_record
|
||||
# [/DEF:test_delete_validation_report_returns_false_for_unknown_record:Function]
|
||||
|
||||
|
||||
# #region test_delete_validation_report_swallows_linked_task_cleanup_failure [TYPE Function]
|
||||
# @BRIEF Verify delete swallows exceptions when cleaning up linked tasks.
|
||||
# @RELATION BINDS_TO -> [test_health_service]
|
||||
# [DEF:test_delete_validation_report_swallows_linked_task_cleanup_failure:Function]
|
||||
# @RELATION: BINDS_TO ->[test_health_service]
|
||||
# @PURPOSE: Verify delete swallows exceptions when cleaning up linked tasks.
|
||||
def test_delete_validation_report_swallows_linked_task_cleanup_failure():
|
||||
db = MagicMock()
|
||||
config_manager = MagicMock()
|
||||
@@ -304,5 +305,5 @@ def test_delete_validation_report_swallows_linked_task_cleanup_failure():
|
||||
assert "task-1" not in task_manager.tasks
|
||||
|
||||
|
||||
# #endregion test_delete_validation_report_swallows_linked_task_cleanup_failure
|
||||
# #endregion test_health_service
|
||||
# [/DEF:test_delete_validation_report_swallows_linked_task_cleanup_failure:Function]
|
||||
# [/DEF:test_health_service:Module]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# #region test_llm_plugin_persistence [C:3] [TYPE Module]
|
||||
# @BRIEF Regression test for ValidationRecord persistence fields populated from task context.
|
||||
# @RELATION VERIFIES -> [DashboardValidationPlugin:Class]
|
||||
# [DEF:test_llm_plugin_persistence:Module]
|
||||
# @RELATION: VERIFIES -> [DashboardValidationPlugin:Class]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Regression test for ValidationRecord persistence fields populated from task context.
|
||||
|
||||
import types
|
||||
import pytest
|
||||
@@ -8,10 +9,11 @@ import pytest
|
||||
from src.plugins.llm_analysis import plugin as plugin_module
|
||||
|
||||
|
||||
# #region _DummyLogger [C:1] [TYPE Class]
|
||||
# @BRIEF Minimal logger shim for TaskContext-like objects used in tests.
|
||||
# @INVARIANT Logging methods are no-ops and must not mutate test state.
|
||||
# @RELATION BINDS_TO -> [test_llm_plugin_persistence:Module]
|
||||
# [DEF:_DummyLogger:Class]
|
||||
# @RELATION: BINDS_TO -> [test_llm_plugin_persistence:Module]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: Minimal logger shim for TaskContext-like objects used in tests.
|
||||
# @INVARIANT: Logging methods are no-ops and must not mutate test state.
|
||||
class _DummyLogger:
|
||||
def with_source(self, _source: str):
|
||||
return self
|
||||
@@ -29,13 +31,14 @@ class _DummyLogger:
|
||||
return None
|
||||
|
||||
|
||||
# #endregion _DummyLogger
|
||||
# [/DEF:_DummyLogger:Class]
|
||||
|
||||
|
||||
# #region _FakeDBSession [C:2] [TYPE Class]
|
||||
# @BRIEF Captures persisted records for assertion and mimics SQLAlchemy session methods used by plugin.
|
||||
# @INVARIANT add/commit/close provide only persistence signals asserted by this test.
|
||||
# @RELATION BINDS_TO -> [test_llm_plugin_persistence:Module]
|
||||
# [DEF:_FakeDBSession:Class]
|
||||
# @RELATION: BINDS_TO -> [test_llm_plugin_persistence:Module]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Captures persisted records for assertion and mimics SQLAlchemy session methods used by plugin.
|
||||
# @INVARIANT: add/commit/close provide only persistence signals asserted by this test.
|
||||
class _FakeDBSession:
|
||||
def __init__(self):
|
||||
self.added = None
|
||||
@@ -52,14 +55,15 @@ class _FakeDBSession:
|
||||
self.closed = True
|
||||
|
||||
|
||||
# #endregion _FakeDBSession
|
||||
# [/DEF:_FakeDBSession:Class]
|
||||
|
||||
|
||||
# #region test_dashboard_validation_plugin_persists_task_and_environment_ids [C:2] [TYPE Function]
|
||||
# @BRIEF Ensure db ValidationRecord includes context.task_id and params.environment_id.
|
||||
# @INVARIANT Assertions remain restricted to persisted task/environment identity fields and session lifecycle signals.
|
||||
# @RELATION BINDS_TO -> [test_llm_plugin_persistence:Module]
|
||||
# @RELATION VERIFIES -> [DashboardValidationPlugin:Class]
|
||||
# [DEF:test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
|
||||
# @RELATION: BINDS_TO -> [test_llm_plugin_persistence:Module]
|
||||
# @RELATION: VERIFIES -> [DashboardValidationPlugin:Class]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Ensure db ValidationRecord includes context.task_id and params.environment_id.
|
||||
# @INVARIANT: Assertions remain restricted to persisted task/environment identity fields and session lifecycle signals.
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_validation_plugin_persists_task_and_environment_ids(
|
||||
tmp_path, monkeypatch
|
||||
@@ -76,10 +80,11 @@ async def test_dashboard_validation_plugin_persists_task_and_environment_ids(
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
# #region _FakeProviderService [C:1] [TYPE Class]
|
||||
# @BRIEF LLM provider service stub returning deterministic provider and decrypted API key for plugin tests.
|
||||
# @INVARIANT Returns same provider and key regardless of provider_id argument; no lookup logic.
|
||||
# @RELATION BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
|
||||
# [DEF:_FakeProviderService:Class]
|
||||
# @RELATION: BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: LLM provider service stub returning deterministic provider and decrypted API key for plugin tests.
|
||||
# @INVARIANT: Returns same provider and key regardless of provider_id argument; no lookup logic.
|
||||
class _FakeProviderService:
|
||||
def __init__(self, _db):
|
||||
return None
|
||||
@@ -90,12 +95,13 @@ async def test_dashboard_validation_plugin_persists_task_and_environment_ids(
|
||||
def get_decrypted_api_key(self, _provider_id):
|
||||
return "a" * 32
|
||||
|
||||
# #endregion _FakeProviderService
|
||||
# [/DEF:_FakeProviderService:Class]
|
||||
|
||||
# #region _FakeScreenshotService [C:1] [TYPE Class]
|
||||
# @BRIEF Screenshot service stub that accepts capture_dashboard calls without side effects.
|
||||
# @INVARIANT capture_dashboard is intentionally permissive for this persistence-focused test and does not validate argument values.
|
||||
# @RELATION BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
|
||||
# [DEF:_FakeScreenshotService:Class]
|
||||
# @RELATION: BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: Screenshot service stub that accepts capture_dashboard calls without side effects.
|
||||
# @INVARIANT: capture_dashboard is intentionally permissive for this persistence-focused test and does not validate argument values.
|
||||
class _FakeScreenshotService:
|
||||
def __init__(self, _env):
|
||||
return None
|
||||
@@ -103,12 +109,13 @@ async def test_dashboard_validation_plugin_persists_task_and_environment_ids(
|
||||
async def capture_dashboard(self, _dashboard_id, _screenshot_path):
|
||||
return None
|
||||
|
||||
# #endregion _FakeScreenshotService
|
||||
# [/DEF:_FakeScreenshotService:Class]
|
||||
|
||||
# #region _FakeLLMClient [C:2] [TYPE Class]
|
||||
# @BRIEF Deterministic LLM client double returning canonical analysis payload for persistence-path assertions.
|
||||
# @INVARIANT analyze_dashboard is side-effect free and returns schema-compatible PASS result.
|
||||
# @RELATION BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
|
||||
# [DEF:_FakeLLMClient:Class]
|
||||
# @RELATION: BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Deterministic LLM client double returning canonical analysis payload for persistence-path assertions.
|
||||
# @INVARIANT: analyze_dashboard is side-effect free and returns schema-compatible PASS result.
|
||||
class _FakeLLMClient:
|
||||
"""Fake LLM client for persistence tests.
|
||||
|
||||
@@ -126,12 +133,13 @@ async def test_dashboard_validation_plugin_persists_task_and_environment_ids(
|
||||
"issues": [],
|
||||
}
|
||||
|
||||
# #endregion _FakeLLMClient
|
||||
# [/DEF:_FakeLLMClient:Class]
|
||||
|
||||
# #region _FakeNotificationService [C:1] [TYPE Class]
|
||||
# @BRIEF Notification service stub that accepts plugin dispatch_report payload without introducing side effects.
|
||||
# @INVARIANT dispatch_report accepts arbitrary keyword payloads because this test verifies persistence fields, not notification payload schema.
|
||||
# @RELATION BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
|
||||
# [DEF:_FakeNotificationService:Class]
|
||||
# @RELATION: BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: Notification service stub that accepts plugin dispatch_report payload without introducing side effects.
|
||||
# @INVARIANT: dispatch_report accepts arbitrary keyword payloads because this test verifies persistence fields, not notification payload schema.
|
||||
class _FakeNotificationService:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
return None
|
||||
@@ -139,12 +147,13 @@ async def test_dashboard_validation_plugin_persists_task_and_environment_ids(
|
||||
async def dispatch_report(self, **_kwargs):
|
||||
return None
|
||||
|
||||
# #endregion _FakeNotificationService
|
||||
# [/DEF:_FakeNotificationService:Class]
|
||||
|
||||
# #region _FakeConfigManager [C:1] [TYPE Class]
|
||||
# @BRIEF Config manager stub providing storage root path and minimal settings for plugin execution path.
|
||||
# @INVARIANT Only storage.root_path and llm fields are safe to access; all other settings fields are absent.
|
||||
# @RELATION BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
|
||||
# [DEF:_FakeConfigManager:Class]
|
||||
# @RELATION: BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: Config manager stub providing storage root path and minimal settings for plugin execution path.
|
||||
# @INVARIANT: Only storage.root_path and llm fields are safe to access; all other settings fields are absent.
|
||||
class _FakeConfigManager:
|
||||
def get_environment(self, _env_id):
|
||||
return env
|
||||
@@ -157,19 +166,20 @@ async def test_dashboard_validation_plugin_persists_task_and_environment_ids(
|
||||
)
|
||||
)
|
||||
|
||||
# #endregion _FakeConfigManager
|
||||
# [/DEF:_FakeConfigManager:Class]
|
||||
|
||||
# #region _FakeSupersetClient [C:1] [TYPE Class]
|
||||
# @BRIEF Superset client stub exposing network.request as a lambda that returns empty result list.
|
||||
# @INVARIANT network.request intentionally accepts arbitrary keyword payloads because response shape, not request signature, is the persistence-path dependency.
|
||||
# @RELATION BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
|
||||
# [DEF:_FakeSupersetClient:Class]
|
||||
# @RELATION: BINDS_TO -> [test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: Superset client stub exposing network.request as a lambda that returns empty result list.
|
||||
# @INVARIANT: network.request intentionally accepts arbitrary keyword payloads because response shape, not request signature, is the persistence-path dependency.
|
||||
class _FakeSupersetClient:
|
||||
def __init__(self, _env):
|
||||
self.network = types.SimpleNamespace(
|
||||
request=lambda **_kwargs: {"result": []}
|
||||
)
|
||||
|
||||
# #endregion _FakeSupersetClient
|
||||
# [/DEF:_FakeSupersetClient:Class]
|
||||
|
||||
monkeypatch.setattr(plugin_module, "SessionLocal", lambda: fake_db)
|
||||
monkeypatch.setattr(plugin_module, "LLMProviderService", _FakeProviderService)
|
||||
@@ -205,4 +215,7 @@ async def test_dashboard_validation_plugin_persists_task_and_environment_ids(
|
||||
assert fake_db.added.environment_id == "env-42"
|
||||
|
||||
|
||||
# #endregion test_dashboard_validation_plugin_persists_task_and_environment_ids
|
||||
# [/DEF:test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
|
||||
|
||||
|
||||
# [/DEF:test_llm_plugin_persistence:Module]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# #region test_llm_prompt_templates [C:3] [TYPE Module] [SEMANTICS tests, llm, prompts, templates, settings]
|
||||
# @BRIEF Validate normalization and rendering behavior for configurable LLM prompt templates.
|
||||
# @LAYER Domain Tests
|
||||
# @INVARIANT All required prompt keys remain available after normalization.
|
||||
# @RELATION DEPENDS_ON -> [llm_prompt_templates]
|
||||
# [DEF:test_llm_prompt_templates:Module]
|
||||
# @COMPLEXITY: 3
|
||||
# @SEMANTICS: tests, llm, prompts, templates, settings
|
||||
# @PURPOSE: Validate normalization and rendering behavior for configurable LLM prompt templates.
|
||||
# @LAYER: Domain Tests
|
||||
# @RELATION: DEPENDS_ON -> [llm_prompt_templates]
|
||||
# @INVARIANT: All required prompt keys remain available after normalization.
|
||||
|
||||
from src.services.llm_prompt_templates import (
|
||||
DEFAULT_LLM_ASSISTANT_SETTINGS,
|
||||
@@ -15,11 +17,12 @@ from src.services.llm_prompt_templates import (
|
||||
)
|
||||
|
||||
|
||||
# #region test_normalize_llm_settings_adds_default_prompts [C:3] [TYPE Function]
|
||||
# @BRIEF Ensure legacy/partial llm settings are expanded with all prompt defaults.
|
||||
# @PRE Input llm settings do not contain complete prompts object.
|
||||
# @POST Returned structure includes required prompt templates with fallback defaults.
|
||||
# @RELATION BINDS_TO -> [test_llm_prompt_templates]
|
||||
# [DEF:test_normalize_llm_settings_adds_default_prompts:Function]
|
||||
# @RELATION: BINDS_TO -> test_llm_prompt_templates
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Ensure legacy/partial llm settings are expanded with all prompt defaults.
|
||||
# @PRE: Input llm settings do not contain complete prompts object.
|
||||
# @POST: Returned structure includes required prompt templates with fallback defaults.
|
||||
def test_normalize_llm_settings_adds_default_prompts():
|
||||
normalized = normalize_llm_settings({"default_provider": "x"})
|
||||
|
||||
@@ -35,14 +38,15 @@ def test_normalize_llm_settings_adds_default_prompts():
|
||||
assert key in normalized
|
||||
|
||||
|
||||
# #endregion test_normalize_llm_settings_adds_default_prompts
|
||||
# [/DEF:test_normalize_llm_settings_adds_default_prompts:Function]
|
||||
|
||||
|
||||
# #region test_normalize_llm_settings_keeps_custom_prompt_values [C:3] [TYPE Function]
|
||||
# @BRIEF Ensure user-customized prompt values are preserved during normalization.
|
||||
# @PRE Input llm settings contain custom prompt override.
|
||||
# @POST Custom prompt value remains unchanged in normalized output.
|
||||
# @RELATION BINDS_TO -> [test_llm_prompt_templates]
|
||||
# [DEF:test_normalize_llm_settings_keeps_custom_prompt_values:Function]
|
||||
# @RELATION: BINDS_TO -> test_llm_prompt_templates
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Ensure user-customized prompt values are preserved during normalization.
|
||||
# @PRE: Input llm settings contain custom prompt override.
|
||||
# @POST: Custom prompt value remains unchanged in normalized output.
|
||||
def test_normalize_llm_settings_keeps_custom_prompt_values():
|
||||
custom = "Doc for {dataset_name} using {columns_json}"
|
||||
normalized = normalize_llm_settings({"prompts": {"documentation_prompt": custom}})
|
||||
@@ -50,14 +54,15 @@ def test_normalize_llm_settings_keeps_custom_prompt_values():
|
||||
assert normalized["prompts"]["documentation_prompt"] == custom
|
||||
|
||||
|
||||
# #endregion test_normalize_llm_settings_keeps_custom_prompt_values
|
||||
# [/DEF:test_normalize_llm_settings_keeps_custom_prompt_values:Function]
|
||||
|
||||
|
||||
# #region test_render_prompt_replaces_known_placeholders [C:3] [TYPE Function]
|
||||
# @BRIEF Ensure template placeholders are deterministically replaced.
|
||||
# @PRE Template contains placeholders matching provided variables.
|
||||
# @POST Rendered prompt string contains substituted values.
|
||||
# @RELATION BINDS_TO -> [test_llm_prompt_templates]
|
||||
# [DEF:test_render_prompt_replaces_known_placeholders:Function]
|
||||
# @RELATION: BINDS_TO -> test_llm_prompt_templates
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Ensure template placeholders are deterministically replaced.
|
||||
# @PRE: Template contains placeholders matching provided variables.
|
||||
# @POST: Rendered prompt string contains substituted values.
|
||||
def test_render_prompt_replaces_known_placeholders():
|
||||
rendered = render_prompt(
|
||||
"Hello {name}, diff={diff}",
|
||||
@@ -67,12 +72,13 @@ def test_render_prompt_replaces_known_placeholders():
|
||||
assert rendered == "Hello bot, diff=A->B"
|
||||
|
||||
|
||||
# #endregion test_render_prompt_replaces_known_placeholders
|
||||
# [/DEF:test_render_prompt_replaces_known_placeholders:Function]
|
||||
|
||||
|
||||
# #region test_is_multimodal_model_detects_known_vision_models [C:2] [TYPE Function]
|
||||
# @BRIEF Ensure multimodal model detection recognizes common vision-capable model names.
|
||||
# @RELATION BINDS_TO -> [test_llm_prompt_templates]
|
||||
# [DEF:test_is_multimodal_model_detects_known_vision_models:Function]
|
||||
# @RELATION: BINDS_TO -> test_llm_prompt_templates
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Ensure multimodal model detection recognizes common vision-capable model names.
|
||||
def test_is_multimodal_model_detects_known_vision_models():
|
||||
assert is_multimodal_model("gpt-4o") is True
|
||||
assert is_multimodal_model("claude-3-5-sonnet") is True
|
||||
@@ -80,12 +86,13 @@ def test_is_multimodal_model_detects_known_vision_models():
|
||||
assert is_multimodal_model("text-only-model") is False
|
||||
|
||||
|
||||
# #endregion test_is_multimodal_model_detects_known_vision_models
|
||||
# [/DEF:test_is_multimodal_model_detects_known_vision_models:Function]
|
||||
|
||||
|
||||
# #region test_resolve_bound_provider_id_prefers_binding_then_default [C:2] [TYPE Function]
|
||||
# @BRIEF Verify provider binding resolution priority.
|
||||
# @RELATION BINDS_TO -> [test_llm_prompt_templates]
|
||||
# [DEF:test_resolve_bound_provider_id_prefers_binding_then_default:Function]
|
||||
# @RELATION: BINDS_TO -> test_llm_prompt_templates
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Verify provider binding resolution priority.
|
||||
def test_resolve_bound_provider_id_prefers_binding_then_default():
|
||||
settings = {
|
||||
"default_provider": "default-1",
|
||||
@@ -95,12 +102,13 @@ def test_resolve_bound_provider_id_prefers_binding_then_default():
|
||||
assert resolve_bound_provider_id(settings, "documentation") == "default-1"
|
||||
|
||||
|
||||
# #endregion test_resolve_bound_provider_id_prefers_binding_then_default
|
||||
# [/DEF:test_resolve_bound_provider_id_prefers_binding_then_default:Function]
|
||||
|
||||
|
||||
# #region test_normalize_llm_settings_keeps_assistant_planner_settings [C:2] [TYPE Function]
|
||||
# @BRIEF Ensure assistant planner provider/model fields are preserved and normalized.
|
||||
# @RELATION BINDS_TO -> [test_llm_prompt_templates]
|
||||
# [DEF:test_normalize_llm_settings_keeps_assistant_planner_settings:Function]
|
||||
# @RELATION: BINDS_TO -> test_llm_prompt_templates
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Ensure assistant planner provider/model fields are preserved and normalized.
|
||||
def test_normalize_llm_settings_keeps_assistant_planner_settings():
|
||||
normalized = normalize_llm_settings(
|
||||
{
|
||||
@@ -112,7 +120,7 @@ def test_normalize_llm_settings_keeps_assistant_planner_settings():
|
||||
assert normalized["assistant_planner_model"] == "gpt-4.1-mini"
|
||||
|
||||
|
||||
# #endregion test_normalize_llm_settings_keeps_assistant_planner_settings
|
||||
# [/DEF:test_normalize_llm_settings_keeps_assistant_planner_settings:Function]
|
||||
|
||||
|
||||
# #endregion test_llm_prompt_templates
|
||||
# [/DEF:test_llm_prompt_templates:Module]
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# #region test_llm_provider [C:3] [TYPE Module] [SEMANTICS tests, llm-provider, encryption, contract]
|
||||
# @BRIEF Contract testing for LLMProviderService and EncryptionManager
|
||||
# @RELATION VERIFIES -> [src.services.llm_provider:Module]
|
||||
# #endregion test_llm_provider
|
||||
# [DEF:test_llm_provider:Module]
|
||||
# @RELATION: VERIFIES -> [src.services.llm_provider:Module]
|
||||
# @COMPLEXITY: 3
|
||||
# @SEMANTICS: tests, llm-provider, encryption, contract
|
||||
# @PURPOSE: Contract testing for LLMProviderService and EncryptionManager
|
||||
# [/DEF:test_llm_provider:Module]
|
||||
|
||||
import pytest
|
||||
import os
|
||||
@@ -12,18 +14,18 @@ from src.services.llm_provider import EncryptionManager, LLMProviderService
|
||||
from src.models.llm import LLMProvider
|
||||
from src.plugins.llm_analysis.models import LLMProviderConfig, LLMProviderType
|
||||
|
||||
# #region _test_encryption_key_fixture [TYPE Global]
|
||||
# @BRIEF Ensure encryption-dependent provider tests run with a valid Fernet key.
|
||||
# @RELATION DEPENDS_ON -> [pytest:Module]
|
||||
# [DEF:_test_encryption_key_fixture:Global]
|
||||
# @PURPOSE: Ensure encryption-dependent provider tests run with a valid Fernet key.
|
||||
# @RELATION: DEPENDS_ON -> [pytest:Module]
|
||||
os.environ.setdefault("ENCRYPTION_KEY", Fernet.generate_key().decode())
|
||||
# #endregion _test_encryption_key_fixture
|
||||
# [/DEF:_test_encryption_key_fixture:Global]
|
||||
|
||||
|
||||
# @TEST_CONTRACT: EncryptionManagerModel -> Invariants
|
||||
# @TEST_INVARIANT: symmetric_encryption
|
||||
# #region test_encryption_cycle [TYPE Function]
|
||||
# @BRIEF Verify EncryptionManager round-trip encryption/decryption invariant for non-empty secrets.
|
||||
# @RELATION BINDS_TO -> [test_llm_provider:Module]
|
||||
# [DEF:test_encryption_cycle:Function]
|
||||
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
|
||||
# @PURPOSE: Verify EncryptionManager round-trip encryption/decryption invariant for non-empty secrets.
|
||||
def test_encryption_cycle():
|
||||
"""Verify encrypted data can be decrypted back to original string."""
|
||||
manager = EncryptionManager()
|
||||
@@ -34,12 +36,12 @@ def test_encryption_cycle():
|
||||
|
||||
|
||||
# @TEST_EDGE: empty_string_encryption
|
||||
# #endregion test_encryption_cycle
|
||||
# [/DEF:test_encryption_cycle:Function]
|
||||
|
||||
|
||||
# #region test_empty_string_encryption [TYPE Function]
|
||||
# @BRIEF Verify EncryptionManager preserves empty-string payloads through encrypt/decrypt cycle.
|
||||
# @RELATION BINDS_TO -> [test_llm_provider:Module]
|
||||
# [DEF:test_empty_string_encryption:Function]
|
||||
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
|
||||
# @PURPOSE: Verify EncryptionManager preserves empty-string payloads through encrypt/decrypt cycle.
|
||||
def test_empty_string_encryption():
|
||||
manager = EncryptionManager()
|
||||
original = ""
|
||||
@@ -48,12 +50,12 @@ def test_empty_string_encryption():
|
||||
|
||||
|
||||
# @TEST_EDGE: decrypt_invalid_data
|
||||
# #endregion test_empty_string_encryption
|
||||
# [/DEF:test_empty_string_encryption:Function]
|
||||
|
||||
|
||||
# #region test_decrypt_invalid_data [TYPE Function]
|
||||
# @BRIEF Ensure decrypt rejects invalid ciphertext input by raising an exception.
|
||||
# @RELATION BINDS_TO -> [test_llm_provider:Module]
|
||||
# [DEF:test_decrypt_invalid_data:Function]
|
||||
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
|
||||
# @PURPOSE: Ensure decrypt rejects invalid ciphertext input by raising an exception.
|
||||
def test_decrypt_invalid_data():
|
||||
manager = EncryptionManager()
|
||||
with pytest.raises(Exception):
|
||||
@@ -61,48 +63,50 @@ def test_decrypt_invalid_data():
|
||||
|
||||
|
||||
# @TEST_FIXTURE: mock_db_session
|
||||
# #endregion test_decrypt_invalid_data
|
||||
# [/DEF:test_decrypt_invalid_data:Function]
|
||||
|
||||
|
||||
# #region mock_db [C:1] [TYPE Fixture]
|
||||
# @BRIEF MagicMock(spec=Session) fixture providing a constrained DB session double for LLMProviderService tests.
|
||||
# @INVARIANT Chained calls beyond Session spec create unconstrained intermediate mocks; only top-level query/add/commit are spec-enforced.
|
||||
# @RELATION BINDS_TO -> [test_llm_provider:Module]
|
||||
# [DEF:mock_db:Fixture]
|
||||
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: MagicMock(spec=Session) fixture providing a constrained DB session double for LLMProviderService tests.
|
||||
# @INVARIANT: Chained calls beyond Session spec create unconstrained intermediate mocks; only top-level query/add/commit are spec-enforced.
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
# @RISK: query() returns unconstrained MagicMock — chain beyond query() has no spec protection. Consider create_autospec(Session) for full chain safety.
|
||||
return MagicMock(spec=Session)
|
||||
|
||||
|
||||
# #endregion mock_db
|
||||
# [/DEF:mock_db:Fixture]
|
||||
|
||||
|
||||
# #region service [C:1] [TYPE Fixture]
|
||||
# @BRIEF LLMProviderService fixture wired to mock_db for provider CRUD tests.
|
||||
# @RELATION BINDS_TO -> [test_llm_provider:Module]
|
||||
# [DEF:service:Fixture]
|
||||
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: LLMProviderService fixture wired to mock_db for provider CRUD tests.
|
||||
@pytest.fixture
|
||||
def service(mock_db):
|
||||
return LLMProviderService(db=mock_db)
|
||||
|
||||
|
||||
# #endregion service
|
||||
# [/DEF:service:Fixture]
|
||||
|
||||
|
||||
# #region test_get_all_providers [TYPE Function]
|
||||
# @BRIEF Verify provider list retrieval issues query/all calls on the backing DB session.
|
||||
# @RELATION BINDS_TO -> [test_llm_provider:Module]
|
||||
# [DEF:test_get_all_providers:Function]
|
||||
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
|
||||
# @PURPOSE: Verify provider list retrieval issues query/all calls on the backing DB session.
|
||||
def test_get_all_providers(service, mock_db):
|
||||
service.get_all_providers()
|
||||
mock_db.query.assert_called()
|
||||
mock_db.query().all.assert_called()
|
||||
|
||||
|
||||
# #endregion test_get_all_providers
|
||||
# [/DEF:test_get_all_providers:Function]
|
||||
|
||||
|
||||
# #region test_create_provider [TYPE Function]
|
||||
# @BRIEF Ensure provider creation persists entity and stores API key in encrypted form.
|
||||
# @RELATION BINDS_TO -> [test_llm_provider:Module]
|
||||
# [DEF:test_create_provider:Function]
|
||||
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
|
||||
# @PURPOSE: Ensure provider creation persists entity and stores API key in encrypted form.
|
||||
def test_create_provider(service, mock_db):
|
||||
config = LLMProviderConfig(
|
||||
provider_type=LLMProviderType.OPENAI,
|
||||
@@ -123,12 +127,12 @@ def test_create_provider(service, mock_db):
|
||||
assert EncryptionManager().decrypt(provider.api_key) == "sk-test"
|
||||
|
||||
|
||||
# #endregion test_create_provider
|
||||
# [/DEF:test_create_provider:Function]
|
||||
|
||||
|
||||
# #region test_get_decrypted_api_key [TYPE Function]
|
||||
# @BRIEF Verify service decrypts stored provider API key for an existing provider record.
|
||||
# @RELATION BINDS_TO -> [test_llm_provider:Module]
|
||||
# [DEF:test_get_decrypted_api_key:Function]
|
||||
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
|
||||
# @PURPOSE: Verify service decrypts stored provider API key for an existing provider record.
|
||||
def test_get_decrypted_api_key(service, mock_db):
|
||||
# Setup mock provider
|
||||
encrypted_key = EncryptionManager().encrypt("secret-value")
|
||||
@@ -139,23 +143,23 @@ def test_get_decrypted_api_key(service, mock_db):
|
||||
assert key == "secret-value"
|
||||
|
||||
|
||||
# #endregion test_get_decrypted_api_key
|
||||
# [/DEF:test_get_decrypted_api_key:Function]
|
||||
|
||||
|
||||
# #region test_get_decrypted_api_key_not_found [TYPE Function]
|
||||
# @BRIEF Verify missing provider lookup returns None instead of attempting decryption.
|
||||
# @RELATION BINDS_TO -> [test_llm_provider:Module]
|
||||
# [DEF:test_get_decrypted_api_key_not_found:Function]
|
||||
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
|
||||
# @PURPOSE: Verify missing provider lookup returns None instead of attempting decryption.
|
||||
def test_get_decrypted_api_key_not_found(service, mock_db):
|
||||
mock_db.query().filter().first.return_value = None
|
||||
assert service.get_decrypted_api_key("missing") is None
|
||||
|
||||
|
||||
# #endregion test_get_decrypted_api_key_not_found
|
||||
# [/DEF:test_get_decrypted_api_key_not_found:Function]
|
||||
|
||||
|
||||
# #region test_update_provider_ignores_masked_placeholder_api_key [TYPE Function]
|
||||
# @BRIEF Ensure masked placeholder API keys do not overwrite previously encrypted provider secrets.
|
||||
# @RELATION BINDS_TO -> [test_llm_provider:Module]
|
||||
# [DEF:test_update_provider_ignores_masked_placeholder_api_key:Function]
|
||||
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
|
||||
# @PURPOSE: Ensure masked placeholder API keys do not overwrite previously encrypted provider secrets.
|
||||
def test_update_provider_ignores_masked_placeholder_api_key(service, mock_db):
|
||||
existing_encrypted = EncryptionManager().encrypt("secret-value")
|
||||
mock_provider = LLMProvider(
|
||||
@@ -186,4 +190,4 @@ def test_update_provider_ignores_masked_placeholder_api_key(service, mock_db):
|
||||
assert updated.is_active is False
|
||||
|
||||
|
||||
# #endregion test_update_provider_ignores_masked_placeholder_api_key
|
||||
# [/DEF:test_update_provider_ignores_masked_placeholder_api_key:Function]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# #region test_rbac_permission_catalog [C:3] [TYPE Module] [SEMANTICS tests, rbac, permissions, catalog, discovery, sync]
|
||||
# @BRIEF Verifies RBAC permission catalog discovery and idempotent synchronization behavior.
|
||||
# @LAYER Service Tests
|
||||
# @INVARIANT Synchronization adds only missing normalized permission pairs.
|
||||
# @RELATION BELONGS_TO -> [SrcRoot]
|
||||
# [DEF:test_rbac_permission_catalog:Module]
|
||||
# @RELATION: BELONGS_TO -> SrcRoot
|
||||
# @COMPLEXITY: 3
|
||||
# @SEMANTICS: tests, rbac, permissions, catalog, discovery, sync
|
||||
# @PURPOSE: Verifies RBAC permission catalog discovery and idempotent synchronization behavior.
|
||||
# @LAYER: Service Tests
|
||||
# @INVARIANT: Synchronization adds only missing normalized permission pairs.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from types import SimpleNamespace
|
||||
@@ -12,11 +14,11 @@ import src.services.rbac_permission_catalog as catalog
|
||||
# [/SECTION: IMPORTS]
|
||||
|
||||
|
||||
# #region test_discover_route_permissions_extracts_declared_pairs_and_ignores_tests [TYPE Function]
|
||||
# @BRIEF Ensures route-scanner extracts has_permission pairs from route files and skips __tests__.
|
||||
# @PRE Temporary route directory contains route and test files.
|
||||
# @POST Returned set includes production route permissions and excludes test-only declarations.
|
||||
# @RELATION BINDS_TO -> [test_rbac_permission_catalog]
|
||||
# [DEF:test_discover_route_permissions_extracts_declared_pairs_and_ignores_tests:Function]
|
||||
# @RELATION: BINDS_TO -> test_rbac_permission_catalog
|
||||
# @PURPOSE: Ensures route-scanner extracts has_permission pairs from route files and skips __tests__.
|
||||
# @PRE: Temporary route directory contains route and test files.
|
||||
# @POST: Returned set includes production route permissions and excludes test-only declarations.
|
||||
def test_discover_route_permissions_extracts_declared_pairs_and_ignores_tests(tmp_path, monkeypatch):
|
||||
routes_dir = tmp_path / "routes"
|
||||
routes_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -47,14 +49,14 @@ def test_discover_route_permissions_extracts_declared_pairs_and_ignores_tests(tm
|
||||
assert ("plugin:migration", "EXECUTE") in discovered
|
||||
assert ("tasks", "WRITE") in discovered
|
||||
assert ("plugin:ignored", "READ") not in discovered
|
||||
# #endregion test_discover_route_permissions_extracts_declared_pairs_and_ignores_tests
|
||||
# [/DEF:test_discover_route_permissions_extracts_declared_pairs_and_ignores_tests:Function]
|
||||
|
||||
|
||||
# #region test_discover_declared_permissions_unions_route_and_plugin_permissions [TYPE Function]
|
||||
# @BRIEF Ensures full catalog includes route-level permissions plus dynamic plugin EXECUTE rights.
|
||||
# @PRE Route discovery and plugin loader both return permission sources.
|
||||
# @POST Result set contains union of both sources.
|
||||
# @RELATION BINDS_TO -> [test_rbac_permission_catalog]
|
||||
# [DEF:test_discover_declared_permissions_unions_route_and_plugin_permissions:Function]
|
||||
# @RELATION: BINDS_TO -> test_rbac_permission_catalog
|
||||
# @PURPOSE: Ensures full catalog includes route-level permissions plus dynamic plugin EXECUTE rights.
|
||||
# @PRE: Route discovery and plugin loader both return permission sources.
|
||||
# @POST: Result set contains union of both sources.
|
||||
def test_discover_declared_permissions_unions_route_and_plugin_permissions(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
catalog,
|
||||
@@ -74,14 +76,14 @@ def test_discover_declared_permissions_unions_route_and_plugin_permissions(monke
|
||||
assert ("plugin:migration", "READ") in discovered
|
||||
assert ("plugin:superset-backup", "EXECUTE") in discovered
|
||||
assert ("plugin:llm_dashboard_validation", "EXECUTE") in discovered
|
||||
# #endregion test_discover_declared_permissions_unions_route_and_plugin_permissions
|
||||
# [/DEF:test_discover_declared_permissions_unions_route_and_plugin_permissions:Function]
|
||||
|
||||
|
||||
# #region test_sync_permission_catalog_inserts_only_missing_normalized_pairs [TYPE Function]
|
||||
# @BRIEF Ensures synchronization inserts only missing pairs and normalizes action/resource tokens.
|
||||
# @PRE DB already contains subset of permissions.
|
||||
# @POST Only missing normalized pairs are inserted and commit is executed once.
|
||||
# @RELATION BINDS_TO -> [test_rbac_permission_catalog]
|
||||
# [DEF:test_sync_permission_catalog_inserts_only_missing_normalized_pairs:Function]
|
||||
# @RELATION: BINDS_TO -> test_rbac_permission_catalog
|
||||
# @PURPOSE: Ensures synchronization inserts only missing pairs and normalizes action/resource tokens.
|
||||
# @PRE: DB already contains subset of permissions.
|
||||
# @POST: Only missing normalized pairs are inserted and commit is executed once.
|
||||
def test_sync_permission_catalog_inserts_only_missing_normalized_pairs():
|
||||
db = MagicMock()
|
||||
db.query.return_value.all.return_value = [
|
||||
@@ -108,14 +110,14 @@ def test_sync_permission_catalog_inserts_only_missing_normalized_pairs():
|
||||
assert inserted_permission.resource == "plugin:migration"
|
||||
assert inserted_permission.action == "READ"
|
||||
db.commit.assert_called_once()
|
||||
# #endregion test_sync_permission_catalog_inserts_only_missing_normalized_pairs
|
||||
# [/DEF:test_sync_permission_catalog_inserts_only_missing_normalized_pairs:Function]
|
||||
|
||||
|
||||
# #region test_sync_permission_catalog_is_noop_when_all_permissions_exist [TYPE Function]
|
||||
# @BRIEF Ensures synchronization is idempotent when all declared pairs already exist.
|
||||
# @PRE DB contains full declared permission set.
|
||||
# @POST No inserts are added and commit is not called.
|
||||
# @RELATION BINDS_TO -> [test_rbac_permission_catalog]
|
||||
# [DEF:test_sync_permission_catalog_is_noop_when_all_permissions_exist:Function]
|
||||
# @RELATION: BINDS_TO -> test_rbac_permission_catalog
|
||||
# @PURPOSE: Ensures synchronization is idempotent when all declared pairs already exist.
|
||||
# @PRE: DB contains full declared permission set.
|
||||
# @POST: No inserts are added and commit is not called.
|
||||
def test_sync_permission_catalog_is_noop_when_all_permissions_exist():
|
||||
db = MagicMock()
|
||||
db.query.return_value.all.return_value = [
|
||||
@@ -136,7 +138,7 @@ def test_sync_permission_catalog_is_noop_when_all_permissions_exist():
|
||||
assert inserted_count == 0
|
||||
db.add.assert_not_called()
|
||||
db.commit.assert_not_called()
|
||||
# #endregion test_sync_permission_catalog_is_noop_when_all_permissions_exist
|
||||
# [/DEF:test_sync_permission_catalog_is_noop_when_all_permissions_exist:Function]
|
||||
|
||||
|
||||
# #endregion test_rbac_permission_catalog
|
||||
# [/DEF:test_rbac_permission_catalog:Module]
|
||||
@@ -1,17 +1,19 @@
|
||||
# #region TestResourceService [C:3] [TYPE Module] [SEMANTICS resource-service, tests, dashboards, datasets, activity]
|
||||
# @BRIEF Unit tests for ResourceService
|
||||
# @LAYER Service
|
||||
# @INVARIANT Resource summaries preserve task linkage and status projection behavior.
|
||||
# @RELATION VERIFIES -> [src.services.resource_service.ResourceService]
|
||||
# [DEF:TestResourceService:Module]
|
||||
# @COMPLEXITY: 3
|
||||
# @SEMANTICS: resource-service, tests, dashboards, datasets, activity
|
||||
# @PURPOSE: Unit tests for ResourceService
|
||||
# @LAYER: Service
|
||||
# @RELATION: VERIFIES ->[src.services.resource_service.ResourceService]
|
||||
# @INVARIANT: Resource summaries preserve task linkage and status projection behavior.
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
# #region test_get_dashboards_with_status [TYPE Function]
|
||||
# @BRIEF Validate dashboard enrichment includes git/task status projections.
|
||||
# @RELATION BINDS_TO -> [TestResourceService]
|
||||
# [DEF:test_get_dashboards_with_status:Function]
|
||||
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||
# @PURPOSE: Validate dashboard enrichment includes git/task status projections.
|
||||
# @TEST: get_dashboards_with_status returns dashboards with git and task status
|
||||
# @PRE: SupersetClient returns dashboard list
|
||||
# @POST: Each dashboard has git_status and last_task fields
|
||||
@@ -71,11 +73,11 @@ async def test_get_dashboards_with_status():
|
||||
assert result[0]["last_task"]["validation_status"] == "FAIL"
|
||||
|
||||
|
||||
# #endregion test_get_dashboards_with_status
|
||||
# [/DEF:test_get_dashboards_with_status:Function]
|
||||
|
||||
|
||||
# #region test_get_datasets_with_status [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [TestResourceService]
|
||||
# [DEF:test_get_datasets_with_status:Function]
|
||||
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||
# @TEST: get_datasets_with_status returns datasets with task status
|
||||
# @PRE: SupersetClient returns dataset list
|
||||
# @POST: Each dataset has last_task field
|
||||
@@ -112,11 +114,11 @@ async def test_get_datasets_with_status():
|
||||
assert result[0]["last_task"]["status"] == "RUNNING"
|
||||
|
||||
|
||||
# #endregion test_get_datasets_with_status
|
||||
# [/DEF:test_get_datasets_with_status:Function]
|
||||
|
||||
|
||||
# #region test_get_activity_summary [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [TestResourceService]
|
||||
# [DEF:test_get_activity_summary:Function]
|
||||
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||
# @TEST: get_activity_summary returns active count and recent tasks
|
||||
# @PRE: tasks list provided
|
||||
# @POST: Returns dict with active_count and recent_tasks
|
||||
@@ -151,11 +153,11 @@ def test_get_activity_summary():
|
||||
assert len(result["recent_tasks"]) == 3
|
||||
|
||||
|
||||
# #endregion test_get_activity_summary
|
||||
# [/DEF:test_get_activity_summary:Function]
|
||||
|
||||
|
||||
# #region test_get_git_status_for_dashboard_no_repo [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [TestResourceService]
|
||||
# [DEF:test_get_git_status_for_dashboard_no_repo:Function]
|
||||
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||
# @TEST: _get_git_status_for_dashboard returns None when no repo exists
|
||||
# @PRE: GitService returns None for repo
|
||||
# @POST: Returns None
|
||||
@@ -174,11 +176,11 @@ def test_get_git_status_for_dashboard_no_repo():
|
||||
assert result["has_repo"] is False
|
||||
|
||||
|
||||
# #endregion test_get_git_status_for_dashboard_no_repo
|
||||
# [/DEF:test_get_git_status_for_dashboard_no_repo:Function]
|
||||
|
||||
|
||||
# #region test_get_last_task_for_resource [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [TestResourceService]
|
||||
# [DEF:test_get_last_task_for_resource:Function]
|
||||
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||
# @TEST: _get_last_task_for_resource returns most recent task for resource
|
||||
# @PRE: tasks list with matching resource_id
|
||||
# @POST: Returns task summary with task_id and status
|
||||
@@ -208,11 +210,11 @@ def test_get_last_task_for_resource():
|
||||
assert result["status"] == "RUNNING"
|
||||
|
||||
|
||||
# #endregion test_get_last_task_for_resource
|
||||
# [/DEF:test_get_last_task_for_resource:Function]
|
||||
|
||||
|
||||
# #region test_extract_resource_name_from_task [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [TestResourceService]
|
||||
# [DEF:test_extract_resource_name_from_task:Function]
|
||||
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||
# @TEST: _extract_resource_name_from_task extracts name from params
|
||||
# @PRE: task has resource_name in params
|
||||
# @POST: Returns resource name or fallback
|
||||
@@ -239,11 +241,11 @@ def test_extract_resource_name_from_task():
|
||||
assert "task-456" in result2
|
||||
|
||||
|
||||
# #endregion test_extract_resource_name_from_task
|
||||
# [/DEF:test_extract_resource_name_from_task:Function]
|
||||
|
||||
|
||||
# #region test_get_last_task_for_resource_empty_tasks [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [TestResourceService]
|
||||
# [DEF:test_get_last_task_for_resource_empty_tasks:Function]
|
||||
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||
# @TEST: _get_last_task_for_resource returns None for empty tasks list
|
||||
# @PRE: tasks is empty list
|
||||
# @POST: Returns None
|
||||
@@ -257,11 +259,11 @@ def test_get_last_task_for_resource_empty_tasks():
|
||||
assert result is None
|
||||
|
||||
|
||||
# #endregion test_get_last_task_for_resource_empty_tasks
|
||||
# [/DEF:test_get_last_task_for_resource_empty_tasks:Function]
|
||||
|
||||
|
||||
# #region test_get_last_task_for_resource_no_match [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [TestResourceService]
|
||||
# [DEF:test_get_last_task_for_resource_no_match:Function]
|
||||
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||
# @TEST: _get_last_task_for_resource returns None when no tasks match resource_id
|
||||
# @PRE: tasks list has no matching resource_id
|
||||
# @POST: Returns None
|
||||
@@ -281,11 +283,11 @@ def test_get_last_task_for_resource_no_match():
|
||||
assert result is None
|
||||
|
||||
|
||||
# #endregion test_get_last_task_for_resource_no_match
|
||||
# [/DEF:test_get_last_task_for_resource_no_match:Function]
|
||||
|
||||
|
||||
# #region test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [TestResourceService]
|
||||
# [DEF:test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes:Function]
|
||||
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||
# @TEST: get_dashboards_with_status handles mixed naive/aware datetimes without comparison errors.
|
||||
# @PRE: Task list includes both timezone-aware and timezone-naive timestamps.
|
||||
# @POST: Latest task is selected deterministically and no exception is raised.
|
||||
@@ -325,11 +327,11 @@ async def test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_dat
|
||||
assert result[0]["last_task"]["task_id"] == "task-aware"
|
||||
|
||||
|
||||
# #endregion test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes
|
||||
# [/DEF:test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes:Function]
|
||||
|
||||
|
||||
# #region test_get_dashboards_with_status_prefers_latest_decisive_validation_status_over_newer_unknown [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [TestResourceService]
|
||||
# [DEF:test_get_dashboards_with_status_prefers_latest_decisive_validation_status_over_newer_unknown:Function]
|
||||
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||
# @TEST: get_dashboards_with_status keeps latest task identity while falling back to older decisive validation status.
|
||||
# @PRE: Same dashboard has older WARN and newer UNKNOWN validation tasks.
|
||||
# @POST: Returned last_task points to newest task but preserves WARN as last meaningful validation state.
|
||||
@@ -375,11 +377,11 @@ async def test_get_dashboards_with_status_prefers_latest_decisive_validation_sta
|
||||
assert result[0]["last_task"]["validation_status"] == "WARN"
|
||||
|
||||
|
||||
# #endregion test_get_dashboards_with_status_prefers_latest_decisive_validation_status_over_newer_unknown
|
||||
# [/DEF:test_get_dashboards_with_status_prefers_latest_decisive_validation_status_over_newer_unknown:Function]
|
||||
|
||||
|
||||
# #region test_get_dashboards_with_status_falls_back_to_latest_unknown_without_decisive_history [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [TestResourceService]
|
||||
# [DEF:test_get_dashboards_with_status_falls_back_to_latest_unknown_without_decisive_history:Function]
|
||||
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||
# @TEST: get_dashboards_with_status still returns newest UNKNOWN when no decisive validation exists.
|
||||
# @PRE: Same dashboard has only UNKNOWN validation tasks.
|
||||
# @POST: Returned last_task keeps newest UNKNOWN task.
|
||||
@@ -424,11 +426,11 @@ async def test_get_dashboards_with_status_falls_back_to_latest_unknown_without_d
|
||||
assert result[0]["last_task"]["validation_status"] == "UNKNOWN"
|
||||
|
||||
|
||||
# #endregion test_get_dashboards_with_status_falls_back_to_latest_unknown_without_decisive_history
|
||||
# [/DEF:test_get_dashboards_with_status_falls_back_to_latest_unknown_without_decisive_history:Function]
|
||||
|
||||
|
||||
# #region test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [TestResourceService]
|
||||
# [DEF:test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at:Function]
|
||||
# @RELATION: BINDS_TO ->[TestResourceService]
|
||||
# @TEST: _get_last_task_for_resource handles mixed naive/aware created_at values.
|
||||
# @PRE: Matching tasks include naive and aware created_at timestamps.
|
||||
# @POST: Latest task is returned without raising datetime comparison errors.
|
||||
@@ -458,7 +460,7 @@ def test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at():
|
||||
assert result["task_id"] == "task-new"
|
||||
|
||||
|
||||
# #endregion test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at
|
||||
# [/DEF:test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at:Function]
|
||||
|
||||
|
||||
# #endregion TestResourceService
|
||||
# [/DEF:TestResourceService:Module]
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# #region auth_service [C:5] [TYPE Module] [SEMANTICS auth, service, business-logic, login, jwt, adfs, jit-provisioning]
|
||||
# @BRIEF Orchestrates credential authentication and ADFS JIT user provisioning.
|
||||
# @LAYER Domain
|
||||
# @PRE Core auth models and security utilities available.
|
||||
# @POST User identity verified and session tokens issued according to role scopes.
|
||||
# @SIDE_EFFECT Writes last login timestamps and JIT-provisions external users.
|
||||
# @DATA_CONTRACT [Credentials | ADFSClaims] -> [UserEntity | SessionToken]
|
||||
# @INVARIANT Authentication succeeds only for active users with valid credentials; issued sessions encode subject and scopes from assigned roles.
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [AuthRepository]
|
||||
# @RELATION DEPENDS_ON -> [verify_password]
|
||||
# @RELATION DEPENDS_ON -> [create_access_token]
|
||||
# @RELATION DEPENDS_ON -> [User]
|
||||
# @RELATION DEPENDS_ON -> [Role]
|
||||
# @INVARIANT: Authentication succeeds only for active users with valid credentials; issued sessions encode subject and scopes from assigned roles.
|
||||
# @PRE: Core auth models and security utilities available.
|
||||
# @POST: User identity verified and session tokens issued according to role scopes.
|
||||
# @SIDE_EFFECT: Writes last login timestamps and JIT-provisions external users.
|
||||
# @DATA_CONTRACT: [Credentials | ADFSClaims] -> [UserEntity | SessionToken]
|
||||
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime
|
||||
@@ -30,28 +30,30 @@ from ..core.logger import belief_scope
|
||||
# @RELATION DEPENDS_ON -> [User]
|
||||
# @RELATION DEPENDS_ON -> [Role]
|
||||
class AuthService:
|
||||
# #region AuthService_init [C:1] [TYPE Function]
|
||||
# @BRIEF Initializes the authentication service with repository access over an active DB session.
|
||||
# @PRE db is a valid SQLAlchemy Session instance bound to the auth persistence context.
|
||||
# @POST self.repo is initialized and ready for auth user/role CRUD operations.
|
||||
# @SIDE_EFFECT Allocates AuthRepository and binds it to the provided Session.
|
||||
# @DATA_CONTRACT Input(Session) -> Model(AuthRepository)
|
||||
# [DEF:AuthService_init:Function]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: Initializes the authentication service with repository access over an active DB session.
|
||||
# @PRE: db is a valid SQLAlchemy Session instance bound to the auth persistence context.
|
||||
# @POST: self.repo is initialized and ready for auth user/role CRUD operations.
|
||||
# @SIDE_EFFECT: Allocates AuthRepository and binds it to the provided Session.
|
||||
# @DATA_CONTRACT: Input(Session) -> Model(AuthRepository)
|
||||
# @PARAM: db (Session) - SQLAlchemy session.
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.repo = AuthRepository(db)
|
||||
|
||||
# #endregion AuthService_init
|
||||
# [/DEF:AuthService_init:Function]
|
||||
|
||||
# #region AuthService.authenticate_user [C:3] [TYPE Function]
|
||||
# @BRIEF Validates credentials and account state for local username/password authentication.
|
||||
# @PRE username and password are non-empty credential inputs.
|
||||
# @POST Returns User only when user exists, is active, and password hash verification succeeds; otherwise returns None.
|
||||
# @SIDE_EFFECT Persists last_login update for successful authentications via repository.
|
||||
# @DATA_CONTRACT Input(str username, str password) -> Output(User | None)
|
||||
# @RELATION DEPENDS_ON -> [AuthRepository]
|
||||
# @RELATION CALLS -> [verify_password]
|
||||
# @RELATION DEPENDS_ON -> [User]
|
||||
# [DEF:AuthService.authenticate_user:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Validates credentials and account state for local username/password authentication.
|
||||
# @PRE: username and password are non-empty credential inputs.
|
||||
# @POST: Returns User only when user exists, is active, and password hash verification succeeds; otherwise returns None.
|
||||
# @SIDE_EFFECT: Persists last_login update for successful authentications via repository.
|
||||
# @DATA_CONTRACT: Input(str username, str password) -> Output(User | None)
|
||||
# @RELATION: [DEPENDS_ON] ->[AuthRepository]
|
||||
# @RELATION: [CALLS] ->[verify_password]
|
||||
# @RELATION: [DEPENDS_ON] ->[User]
|
||||
# @PARAM: username (str) - The username.
|
||||
# @PARAM: password (str) - The plain password.
|
||||
# @RETURN: Optional[User] - The authenticated user or None.
|
||||
@@ -71,17 +73,18 @@ class AuthService:
|
||||
|
||||
return user
|
||||
|
||||
# #endregion AuthService.authenticate_user
|
||||
# [/DEF:AuthService.authenticate_user:Function]
|
||||
|
||||
# #region AuthService.create_session [C:3] [TYPE Function]
|
||||
# @BRIEF Issues an access token payload for an already authenticated user.
|
||||
# @PRE user is a valid User entity containing username and iterable roles with role.name values.
|
||||
# @POST Returns session dict with non-empty access_token and token_type='bearer'.
|
||||
# @SIDE_EFFECT Generates signed JWT via auth JWT provider.
|
||||
# @DATA_CONTRACT Input(User) -> Output(Dict[str, str]{access_token, token_type})
|
||||
# @RELATION CALLS -> [create_access_token]
|
||||
# @RELATION DEPENDS_ON -> [User]
|
||||
# @RELATION DEPENDS_ON -> [Role]
|
||||
# [DEF:AuthService.create_session:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Issues an access token payload for an already authenticated user.
|
||||
# @PRE: user is a valid User entity containing username and iterable roles with role.name values.
|
||||
# @POST: Returns session dict with non-empty access_token and token_type='bearer'.
|
||||
# @SIDE_EFFECT: Generates signed JWT via auth JWT provider.
|
||||
# @DATA_CONTRACT: Input(User) -> Output(Dict[str, str]{access_token, token_type})
|
||||
# @RELATION: [CALLS] ->[create_access_token]
|
||||
# @RELATION: [DEPENDS_ON] ->[User]
|
||||
# @RELATION: [DEPENDS_ON] ->[Role]
|
||||
# @PARAM: user (User) - The authenticated user.
|
||||
# @RETURN: Dict[str, str] - Session data.
|
||||
def create_session(self, user: User) -> Dict[str, str]:
|
||||
@@ -92,17 +95,18 @@ class AuthService:
|
||||
)
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
# #endregion AuthService.create_session
|
||||
# [/DEF:AuthService.create_session:Function]
|
||||
|
||||
# #region AuthService.provision_adfs_user [C:3] [TYPE Function]
|
||||
# @BRIEF Performs ADFS Just-In-Time provisioning and role synchronization from AD group mappings.
|
||||
# @PRE user_info contains identity claims where at least one of 'upn' or 'email' is present; 'groups' may be absent.
|
||||
# @POST Returns persisted user entity with roles synchronized to mapped AD groups and refreshed state.
|
||||
# @SIDE_EFFECT May insert new User, mutate user.roles, commit transaction, and refresh ORM state.
|
||||
# @DATA_CONTRACT Input(Dict[str, Any]{upn|email, email, groups[]}) -> Output(User persisted)
|
||||
# @RELATION DEPENDS_ON -> [AuthRepository]
|
||||
# @RELATION DEPENDS_ON -> [User]
|
||||
# @RELATION DEPENDS_ON -> [Role]
|
||||
# [DEF:AuthService.provision_adfs_user:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Performs ADFS Just-In-Time provisioning and role synchronization from AD group mappings.
|
||||
# @PRE: user_info contains identity claims where at least one of 'upn' or 'email' is present; 'groups' may be absent.
|
||||
# @POST: Returns persisted user entity with roles synchronized to mapped AD groups and refreshed state.
|
||||
# @SIDE_EFFECT: May insert new User, mutate user.roles, commit transaction, and refresh ORM state.
|
||||
# @DATA_CONTRACT: Input(Dict[str, Any]{upn|email, email, groups[]}) -> Output(User persisted)
|
||||
# @RELATION: [DEPENDS_ON] ->[AuthRepository]
|
||||
# @RELATION: [DEPENDS_ON] ->[User]
|
||||
# @RELATION: [DEPENDS_ON] ->[Role]
|
||||
# @PARAM: user_info (Dict[str, Any]) - Claims from ADFS token.
|
||||
# @RETURN: User - The provisioned user.
|
||||
def provision_adfs_user(self, user_info: Dict[str, Any]) -> User:
|
||||
@@ -134,7 +138,9 @@ class AuthService:
|
||||
|
||||
return user
|
||||
|
||||
# #endregion AuthService.provision_adfs_user
|
||||
# [/DEF:AuthService.provision_adfs_user:Function]
|
||||
|
||||
|
||||
# #endregion AuthService
|
||||
|
||||
# #endregion auth_service
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# #region CleanReleaseContracts [C:3] [TYPE Module]
|
||||
# @BRIEF Publish the canonical semantic root for the clean-release backend service cluster.
|
||||
# @LAYER Domain
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [ComplianceOrchestrator]
|
||||
# @RELATION DEPENDS_ON -> [ManifestBuilder]
|
||||
# @RELATION DEPENDS_ON -> [PolicyEngine]
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# #region TestAuditService [C:3] [TYPE Module] [SEMANTICS tests, clean-release, audit, logging]
|
||||
# @BRIEF Validate audit hooks emit expected log patterns for clean release lifecycle.
|
||||
# @LAYER Infra
|
||||
# @RELATION DEPENDS_ON -> [AuditService]
|
||||
# [DEF:TestAuditService:Module]
|
||||
# @RELATION: [DEPENDS_ON] ->[AuditService]
|
||||
# @COMPLEXITY: 3
|
||||
# @SEMANTICS: tests, clean-release, audit, logging
|
||||
# @PURPOSE: Validate audit hooks emit expected log patterns for clean release lifecycle.
|
||||
# @LAYER: Infra
|
||||
|
||||
from unittest.mock import patch
|
||||
from src.services.clean_release.audit_service import (
|
||||
@@ -12,9 +14,9 @@ from src.services.clean_release.audit_service import (
|
||||
|
||||
|
||||
@patch("src.services.clean_release.audit_service.logger")
|
||||
# #region test_audit_preparation [TYPE Function]
|
||||
# @BRIEF Verify audit preparation stage correctly initializes and validates candidate state.
|
||||
# @RELATION BINDS_TO -> [TestAuditService]
|
||||
# [DEF:test_audit_preparation:Function]
|
||||
# @RELATION: BINDS_TO -> TestAuditService
|
||||
# @PURPOSE: Verify audit preparation stage correctly initializes and validates candidate state.
|
||||
def test_audit_preparation(mock_logger):
|
||||
audit_preparation("cand-1", "PREPARED")
|
||||
mock_logger.info.assert_called_with(
|
||||
@@ -22,13 +24,13 @@ def test_audit_preparation(mock_logger):
|
||||
)
|
||||
|
||||
|
||||
# #endregion test_audit_preparation
|
||||
# [/DEF:test_audit_preparation:Function]
|
||||
|
||||
|
||||
@patch("src.services.clean_release.audit_service.logger")
|
||||
# #region test_audit_check_run [TYPE Function]
|
||||
# @BRIEF Verify audit check run executes all checks and collects results.
|
||||
# @RELATION BINDS_TO -> [TestAuditService]
|
||||
# [DEF:test_audit_check_run:Function]
|
||||
# @RELATION: BINDS_TO -> TestAuditService
|
||||
# @PURPOSE: Verify audit check run executes all checks and collects results.
|
||||
def test_audit_check_run(mock_logger):
|
||||
audit_check_run("check-1", "COMPLIANT")
|
||||
mock_logger.info.assert_called_with(
|
||||
@@ -36,13 +38,13 @@ def test_audit_check_run(mock_logger):
|
||||
)
|
||||
|
||||
|
||||
# #endregion test_audit_check_run
|
||||
# [/DEF:test_audit_check_run:Function]
|
||||
|
||||
|
||||
@patch("src.services.clean_release.audit_service.logger")
|
||||
# #region test_audit_report [TYPE Function]
|
||||
# @BRIEF Verify audit report generation aggregates check results into a structured report.
|
||||
# @RELATION BINDS_TO -> [TestAuditService]
|
||||
# [DEF:test_audit_report:Function]
|
||||
# @RELATION: BINDS_TO -> TestAuditService
|
||||
# @PURPOSE: Verify audit report generation aggregates check results into a structured report.
|
||||
def test_audit_report(mock_logger):
|
||||
audit_report("rep-1", "cand-1")
|
||||
mock_logger.info.assert_called_with(
|
||||
@@ -50,5 +52,5 @@ def test_audit_report(mock_logger):
|
||||
)
|
||||
|
||||
|
||||
# #endregion test_audit_report
|
||||
# #endregion TestAuditService
|
||||
# [/DEF:test_audit_report:Function]
|
||||
# [/DEF:TestAuditService:Module]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# #region TestComplianceOrchestrator [C:3] [TYPE Module] [SEMANTICS tests, clean-release, orchestrator, stage-state-machine]
|
||||
# @BRIEF Validate compliance orchestrator stage transitions and final status derivation.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT Failed mandatory stage forces BLOCKED terminal status.
|
||||
# @RELATION DEPENDS_ON -> [ComplianceOrchestrator]
|
||||
# [DEF:TestComplianceOrchestrator:Module]
|
||||
# @RELATION: [DEPENDS_ON] ->[ComplianceOrchestrator]
|
||||
# @COMPLEXITY: 3
|
||||
# @SEMANTICS: tests, clean-release, orchestrator, stage-state-machine
|
||||
# @PURPOSE: Validate compliance orchestrator stage transitions and final status derivation.
|
||||
# @LAYER: Domain
|
||||
# @INVARIANT: Failed mandatory stage forces BLOCKED terminal status.
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -21,9 +23,9 @@ from src.services.clean_release.report_builder import ComplianceReportBuilder
|
||||
from src.services.clean_release.repository import CleanReleaseRepository
|
||||
|
||||
|
||||
# #region test_orchestrator_stage_failure_blocks_release [TYPE Function]
|
||||
# @BRIEF Verify mandatory stage failure forces BLOCKED final status.
|
||||
# @RELATION BINDS_TO -> [TestComplianceOrchestrator]
|
||||
# [DEF:test_orchestrator_stage_failure_blocks_release:Function]
|
||||
# @RELATION: BINDS_TO -> TestComplianceOrchestrator
|
||||
# @PURPOSE: Verify mandatory stage failure forces BLOCKED final status.
|
||||
def test_orchestrator_stage_failure_blocks_release():
|
||||
repository = CleanReleaseRepository()
|
||||
orchestrator = CleanComplianceOrchestrator(repository)
|
||||
@@ -64,12 +66,12 @@ def test_orchestrator_stage_failure_blocks_release():
|
||||
assert run.final_status == CheckFinalStatus.BLOCKED
|
||||
|
||||
|
||||
# #endregion test_orchestrator_stage_failure_blocks_release
|
||||
# [/DEF:test_orchestrator_stage_failure_blocks_release:Function]
|
||||
|
||||
|
||||
# #region test_orchestrator_compliant_candidate [TYPE Function]
|
||||
# @BRIEF Verify happy path where all mandatory stages pass yields COMPLIANT.
|
||||
# @RELATION BINDS_TO -> [TestComplianceOrchestrator]
|
||||
# [DEF:test_orchestrator_compliant_candidate:Function]
|
||||
# @RELATION: BINDS_TO -> TestComplianceOrchestrator
|
||||
# @PURPOSE: Verify happy path where all mandatory stages pass yields COMPLIANT.
|
||||
def test_orchestrator_compliant_candidate():
|
||||
repository = CleanReleaseRepository()
|
||||
orchestrator = CleanComplianceOrchestrator(repository)
|
||||
@@ -110,12 +112,12 @@ def test_orchestrator_compliant_candidate():
|
||||
assert run.final_status == CheckFinalStatus.COMPLIANT
|
||||
|
||||
|
||||
# #endregion test_orchestrator_compliant_candidate
|
||||
# [/DEF:test_orchestrator_compliant_candidate:Function]
|
||||
|
||||
|
||||
# #region test_orchestrator_missing_stage_result [TYPE Function]
|
||||
# @BRIEF Verify incomplete mandatory stage set cannot end as COMPLIANT and results in FAILED.
|
||||
# @RELATION BINDS_TO -> [TestComplianceOrchestrator]
|
||||
# [DEF:test_orchestrator_missing_stage_result:Function]
|
||||
# @RELATION: BINDS_TO -> TestComplianceOrchestrator
|
||||
# @PURPOSE: Verify incomplete mandatory stage set cannot end as COMPLIANT and results in FAILED.
|
||||
def test_orchestrator_missing_stage_result():
|
||||
repository = CleanReleaseRepository()
|
||||
orchestrator = CleanComplianceOrchestrator(repository)
|
||||
@@ -136,12 +138,12 @@ def test_orchestrator_missing_stage_result():
|
||||
assert run.final_status == CheckFinalStatus.FAILED
|
||||
|
||||
|
||||
# #endregion test_orchestrator_missing_stage_result
|
||||
# [/DEF:test_orchestrator_missing_stage_result:Function]
|
||||
|
||||
|
||||
# #region test_orchestrator_report_generation_error [TYPE Function]
|
||||
# @BRIEF Verify downstream report errors do not mutate orchestrator final status.
|
||||
# @RELATION BINDS_TO -> [TestComplianceOrchestrator]
|
||||
# [DEF:test_orchestrator_report_generation_error:Function]
|
||||
# @RELATION: BINDS_TO -> TestComplianceOrchestrator
|
||||
# @PURPOSE: Verify downstream report errors do not mutate orchestrator final status.
|
||||
def test_orchestrator_report_generation_error():
|
||||
repository = CleanReleaseRepository()
|
||||
orchestrator = CleanComplianceOrchestrator(repository)
|
||||
@@ -162,5 +164,5 @@ def test_orchestrator_report_generation_error():
|
||||
assert run.final_status == CheckFinalStatus.FAILED
|
||||
|
||||
|
||||
# #endregion test_orchestrator_report_generation_error
|
||||
# #endregion TestComplianceOrchestrator
|
||||
# [/DEF:test_orchestrator_report_generation_error:Function]
|
||||
# [/DEF:TestComplianceOrchestrator:Module]
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
# #region TestManifestBuilder [C:5] [TYPE Module] [SEMANTICS tests, clean-release, manifest, deterministic]
|
||||
# @BRIEF Validate deterministic manifest generation behavior for US1.
|
||||
# @LAYER Domain
|
||||
# @PRE Test fixtures are properly initialized
|
||||
# @POST All test assertions pass
|
||||
# @SIDE_EFFECT None - test isolation
|
||||
# @DATA_CONTRACT TestInput -> TestOutput
|
||||
# @INVARIANT Same input artifacts produce identical deterministic hash.
|
||||
# @RELATION DEPENDS_ON -> [ManifestBuilder]
|
||||
# [DEF:TestManifestBuilder:Module]
|
||||
# @COMPLEXITY: 5
|
||||
# @SEMANTICS: tests, clean-release, manifest, deterministic
|
||||
# @PURPOSE: Validate deterministic manifest generation behavior for US1.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: [DEPENDS_ON] ->[ManifestBuilder]
|
||||
# @INVARIANT: Same input artifacts produce identical deterministic hash.
|
||||
# @PRE: Test fixtures are properly initialized
|
||||
# @POST: All test assertions pass
|
||||
# @SIDE_EFFECT: None - test isolation
|
||||
# @DATA_CONTRACT: TestInput -> TestOutput
|
||||
|
||||
from src.services.clean_release.manifest_builder import build_distribution_manifest
|
||||
|
||||
|
||||
# #region test_manifest_deterministic_hash_for_same_input [TYPE Function]
|
||||
# @BRIEF Ensure hash is stable for same candidate/policy/artifact input.
|
||||
# @PRE Same input lists are passed twice.
|
||||
# @POST Hash and summary remain identical.
|
||||
# @RELATION BINDS_TO -> [TestManifestBuilder]
|
||||
# [DEF:test_manifest_deterministic_hash_for_same_input:Function]
|
||||
# @RELATION: BINDS_TO -> TestManifestBuilder
|
||||
# @PURPOSE: Ensure hash is stable for same candidate/policy/artifact input.
|
||||
# @PRE: Same input lists are passed twice.
|
||||
# @POST: Hash and summary remain identical.
|
||||
def test_manifest_deterministic_hash_for_same_input():
|
||||
artifacts = [
|
||||
{
|
||||
@@ -52,5 +54,5 @@ def test_manifest_deterministic_hash_for_same_input():
|
||||
assert manifest1.summary.excluded_count == manifest2.summary.excluded_count
|
||||
|
||||
|
||||
# #endregion test_manifest_deterministic_hash_for_same_input
|
||||
# #endregion TestManifestBuilder
|
||||
# [/DEF:test_manifest_deterministic_hash_for_same_input:Function]
|
||||
# [/DEF:TestManifestBuilder:Module]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# #region TestPolicyEngine [TYPE Module]
|
||||
# @BRIEF Contract testing for CleanPolicyEngine
|
||||
# @RELATION DEPENDS_ON -> [PolicyEngine]
|
||||
# #endregion TestPolicyEngine
|
||||
# [DEF:TestPolicyEngine:Module]
|
||||
# @RELATION: [DEPENDS_ON] ->[PolicyEngine]
|
||||
# @PURPOSE: Contract testing for CleanPolicyEngine
|
||||
# [/DEF:TestPolicyEngine:Module]
|
||||
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
@@ -48,9 +48,9 @@ def enterprise_clean_setup():
|
||||
|
||||
|
||||
# @TEST_SCENARIO: policy_valid
|
||||
# #region test_policy_valid [TYPE Function]
|
||||
# @BRIEF Verify policy validation passes when all required fields are present and valid.
|
||||
# @RELATION BINDS_TO -> [TestPolicyEngine]
|
||||
# [DEF:test_policy_valid:Function]
|
||||
# @RELATION: BINDS_TO -> TestPolicyEngine
|
||||
# @PURPOSE: Verify policy validation passes when all required fields are present and valid.
|
||||
def test_policy_valid(enterprise_clean_setup):
|
||||
policy, registry = enterprise_clean_setup
|
||||
engine = CleanPolicyEngine(policy, registry)
|
||||
@@ -60,12 +60,12 @@ def test_policy_valid(enterprise_clean_setup):
|
||||
|
||||
|
||||
# @TEST_EDGE: missing_registry_ref
|
||||
# #endregion test_policy_valid
|
||||
# [/DEF:test_policy_valid:Function]
|
||||
|
||||
|
||||
# #region test_missing_registry_ref [TYPE Function]
|
||||
# @BRIEF Verify policy validation fails when registry_ref is missing.
|
||||
# @RELATION BINDS_TO -> [TestPolicyEngine]
|
||||
# [DEF:test_missing_registry_ref:Function]
|
||||
# @RELATION: BINDS_TO -> TestPolicyEngine
|
||||
# @PURPOSE: Verify policy validation fails when registry_ref is missing.
|
||||
def test_missing_registry_ref(enterprise_clean_setup):
|
||||
policy, registry = enterprise_clean_setup
|
||||
policy.internal_source_registry_ref = " "
|
||||
@@ -76,12 +76,12 @@ def test_missing_registry_ref(enterprise_clean_setup):
|
||||
|
||||
|
||||
# @TEST_EDGE: conflicting_registry
|
||||
# #endregion test_missing_registry_ref
|
||||
# [/DEF:test_missing_registry_ref:Function]
|
||||
|
||||
|
||||
# #region test_conflicting_registry [TYPE Function]
|
||||
# @BRIEF Verify policy engine rejects conflicting registry references.
|
||||
# @RELATION BINDS_TO -> [TestPolicyEngine]
|
||||
# [DEF:test_conflicting_registry:Function]
|
||||
# @RELATION: BINDS_TO -> TestPolicyEngine
|
||||
# @PURPOSE: Verify policy engine rejects conflicting registry references.
|
||||
def test_conflicting_registry(enterprise_clean_setup):
|
||||
policy, registry = enterprise_clean_setup
|
||||
registry.registry_id = "WRONG-REG"
|
||||
@@ -95,12 +95,12 @@ def test_conflicting_registry(enterprise_clean_setup):
|
||||
|
||||
|
||||
# @TEST_INVARIANT: deterministic_classification
|
||||
# #endregion test_conflicting_registry
|
||||
# [/DEF:test_conflicting_registry:Function]
|
||||
|
||||
|
||||
# #region test_classify_artifact [TYPE Function]
|
||||
# @BRIEF Verify policy engine correctly classifies artifacts based on source and type.
|
||||
# @RELATION BINDS_TO -> [TestPolicyEngine]
|
||||
# [DEF:test_classify_artifact:Function]
|
||||
# @RELATION: BINDS_TO -> TestPolicyEngine
|
||||
# @PURPOSE: Verify policy engine correctly classifies artifacts based on source and type.
|
||||
def test_classify_artifact(enterprise_clean_setup):
|
||||
policy, registry = enterprise_clean_setup
|
||||
engine = CleanPolicyEngine(policy, registry)
|
||||
@@ -120,12 +120,12 @@ def test_classify_artifact(enterprise_clean_setup):
|
||||
|
||||
|
||||
# @TEST_EDGE: external_endpoint
|
||||
# #endregion test_classify_artifact
|
||||
# [/DEF:test_classify_artifact:Function]
|
||||
|
||||
|
||||
# #region test_validate_resource_source [TYPE Function]
|
||||
# @BRIEF Verify validate_resource_source correctly validates or rejects resource source identifiers.
|
||||
# @RELATION BINDS_TO -> [TestPolicyEngine]
|
||||
# [DEF:test_validate_resource_source:Function]
|
||||
# @RELATION: BINDS_TO -> TestPolicyEngine
|
||||
# @PURPOSE: Verify validate_resource_source correctly validates or rejects resource source identifiers.
|
||||
def test_validate_resource_source(enterprise_clean_setup):
|
||||
policy, registry = enterprise_clean_setup
|
||||
engine = CleanPolicyEngine(policy, registry)
|
||||
@@ -141,12 +141,12 @@ def test_validate_resource_source(enterprise_clean_setup):
|
||||
assert res_fail.violation["blocked_release"] is True
|
||||
|
||||
|
||||
# #endregion test_validate_resource_source
|
||||
# [/DEF:test_validate_resource_source:Function]
|
||||
|
||||
|
||||
# #region test_evaluate_candidate [TYPE Function]
|
||||
# @BRIEF Verify policy engine evaluates release candidates against configured policies.
|
||||
# @RELATION BINDS_TO -> [TestPolicyEngine]
|
||||
# [DEF:test_evaluate_candidate:Function]
|
||||
# @RELATION: BINDS_TO -> TestPolicyEngine
|
||||
# @PURPOSE: Verify policy engine evaluates release candidates against configured policies.
|
||||
def test_evaluate_candidate(enterprise_clean_setup):
|
||||
policy, registry = enterprise_clean_setup
|
||||
engine = CleanPolicyEngine(policy, registry)
|
||||
@@ -169,4 +169,4 @@ def test_evaluate_candidate(enterprise_clean_setup):
|
||||
assert violations[1]["category"] == "external-source"
|
||||
|
||||
|
||||
# #endregion test_evaluate_candidate
|
||||
# [/DEF:test_evaluate_candidate:Function]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# #region TestPreparationService [C:3] [TYPE Module] [SEMANTICS tests, clean-release, preparation, flow]
|
||||
# @BRIEF Validate release candidate preparation flow, including policy evaluation and manifest persisting.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT Candidate preparation always persists manifest and candidate status deterministically.
|
||||
# @RELATION DEPENDS_ON -> [PreparationService]
|
||||
# [DEF:TestPreparationService:Module]
|
||||
# @COMPLEXITY: 3
|
||||
# @SEMANTICS: tests, clean-release, preparation, flow
|
||||
# @PURPOSE: Validate release candidate preparation flow, including policy evaluation and manifest persisting.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: [DEPENDS_ON] ->[PreparationService]
|
||||
# @INVARIANT: Candidate preparation always persists manifest and candidate status deterministically.
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -20,9 +22,9 @@ from src.models.clean_release import (
|
||||
from src.services.clean_release.preparation_service import prepare_candidate
|
||||
|
||||
|
||||
# #region _mock_policy [TYPE Function]
|
||||
# @BRIEF Build a valid clean profile policy fixture for preparation tests.
|
||||
# @RELATION BINDS_TO -> [TestPreparationService]
|
||||
# [DEF:_mock_policy:Function]
|
||||
# @RELATION: BINDS_TO -> TestPreparationService
|
||||
# @PURPOSE: Build a valid clean profile policy fixture for preparation tests.
|
||||
def _mock_policy() -> CleanProfilePolicy:
|
||||
return CleanProfilePolicy(
|
||||
policy_id="pol-1",
|
||||
@@ -37,12 +39,12 @@ def _mock_policy() -> CleanProfilePolicy:
|
||||
)
|
||||
|
||||
|
||||
# #endregion _mock_policy
|
||||
# [/DEF:_mock_policy:Function]
|
||||
|
||||
|
||||
# #region _mock_registry [TYPE Function]
|
||||
# @BRIEF Build an internal-only source registry fixture for preparation tests.
|
||||
# @RELATION BINDS_TO -> [TestPreparationService]
|
||||
# [DEF:_mock_registry:Function]
|
||||
# @RELATION: BINDS_TO -> TestPreparationService
|
||||
# @PURPOSE: Build an internal-only source registry fixture for preparation tests.
|
||||
def _mock_registry() -> ResourceSourceRegistry:
|
||||
return ResourceSourceRegistry(
|
||||
registry_id="reg-1",
|
||||
@@ -61,12 +63,12 @@ def _mock_registry() -> ResourceSourceRegistry:
|
||||
)
|
||||
|
||||
|
||||
# #endregion _mock_registry
|
||||
# [/DEF:_mock_registry:Function]
|
||||
|
||||
|
||||
# #region _mock_candidate [TYPE Function]
|
||||
# @BRIEF Build a draft release candidate fixture with provided identifier.
|
||||
# @RELATION BINDS_TO -> [TestPreparationService]
|
||||
# [DEF:_mock_candidate:Function]
|
||||
# @RELATION: BINDS_TO -> TestPreparationService
|
||||
# @PURPOSE: Build a draft release candidate fixture with provided identifier.
|
||||
def _mock_candidate(candidate_id: str) -> ReleaseCandidate:
|
||||
return ReleaseCandidate(
|
||||
candidate_id=candidate_id,
|
||||
@@ -79,12 +81,12 @@ def _mock_candidate(candidate_id: str) -> ReleaseCandidate:
|
||||
)
|
||||
|
||||
|
||||
# #endregion _mock_candidate
|
||||
# [/DEF:_mock_candidate:Function]
|
||||
|
||||
|
||||
# #region test_prepare_candidate_success [TYPE Function]
|
||||
# @BRIEF Verify candidate transitions to PREPARED when evaluation returns no violations.
|
||||
# @RELATION BINDS_TO -> [TestPreparationService]
|
||||
# [DEF:test_prepare_candidate_success:Function]
|
||||
# @RELATION: BINDS_TO -> TestPreparationService
|
||||
# @PURPOSE: Verify candidate transitions to PREPARED when evaluation returns no violations.
|
||||
# @TEST_CONTRACT: [valid_candidate + active_policy + internal_sources + no_violations] -> [status=PREPARED, manifest_persisted, candidate_saved]
|
||||
# @TEST_SCENARIO: [prepare_success] -> [prepared status and persistence side effects are produced]
|
||||
# @TEST_FIXTURE: [INLINE_MOCKS] -> INLINE_JSON
|
||||
@@ -131,12 +133,12 @@ def test_prepare_candidate_success():
|
||||
repository.save_candidate.assert_called_with(candidate)
|
||||
|
||||
|
||||
# #endregion test_prepare_candidate_success
|
||||
# [/DEF:test_prepare_candidate_success:Function]
|
||||
|
||||
|
||||
# #region test_prepare_candidate_with_violations [TYPE Function]
|
||||
# @BRIEF Verify candidate transitions to BLOCKED when evaluation returns blocking violations.
|
||||
# @RELATION BINDS_TO -> [TestPreparationService]
|
||||
# [DEF:test_prepare_candidate_with_violations:Function]
|
||||
# @RELATION: BINDS_TO -> TestPreparationService
|
||||
# @PURPOSE: Verify candidate transitions to BLOCKED when evaluation returns blocking violations.
|
||||
# @TEST_CONTRACT: [valid_candidate + active_policy + evaluation_with_violations] -> [status=BLOCKED, violations_exposed]
|
||||
# @TEST_SCENARIO: [prepare_blocked_due_to_policy] -> [blocked status and violation list are produced]
|
||||
# @TEST_FIXTURE: [INLINE_MOCKS] -> INLINE_JSON
|
||||
@@ -182,12 +184,12 @@ def test_prepare_candidate_with_violations():
|
||||
assert len(result["violations"]) == 1
|
||||
|
||||
|
||||
# #endregion test_prepare_candidate_with_violations
|
||||
# [/DEF:test_prepare_candidate_with_violations:Function]
|
||||
|
||||
|
||||
# #region test_prepare_candidate_not_found [TYPE Function]
|
||||
# @BRIEF Verify preparation raises ValueError when candidate does not exist.
|
||||
# @RELATION BINDS_TO -> [TestPreparationService]
|
||||
# [DEF:test_prepare_candidate_not_found:Function]
|
||||
# @RELATION: BINDS_TO -> TestPreparationService
|
||||
# @PURPOSE: Verify preparation raises ValueError when candidate does not exist.
|
||||
# @TEST_CONTRACT: [missing_candidate] -> [ValueError('Candidate not found')]
|
||||
# @TEST_SCENARIO: [prepare_missing_candidate] -> [raises candidate not found error]
|
||||
# @TEST_FIXTURE: [INLINE_MOCKS] -> INLINE_JSON
|
||||
@@ -201,12 +203,12 @@ def test_prepare_candidate_not_found():
|
||||
prepare_candidate(repository, "non-existent", [], [], "op")
|
||||
|
||||
|
||||
# #endregion test_prepare_candidate_not_found
|
||||
# [/DEF:test_prepare_candidate_not_found:Function]
|
||||
|
||||
|
||||
# #region test_prepare_candidate_no_active_policy [TYPE Function]
|
||||
# @BRIEF Verify preparation raises ValueError when no active policy is available.
|
||||
# @RELATION BINDS_TO -> [TestPreparationService]
|
||||
# [DEF:test_prepare_candidate_no_active_policy:Function]
|
||||
# @RELATION: BINDS_TO -> TestPreparationService
|
||||
# @PURPOSE: Verify preparation raises ValueError when no active policy is available.
|
||||
# @TEST_CONTRACT: [candidate_present + missing_active_policy] -> [ValueError('Active clean policy not found')]
|
||||
# @TEST_SCENARIO: [prepare_missing_policy] -> [raises active policy missing error]
|
||||
# @TEST_FIXTURE: [INLINE_MOCKS] -> INLINE_JSON
|
||||
@@ -221,7 +223,7 @@ def test_prepare_candidate_no_active_policy():
|
||||
prepare_candidate(repository, "cand-1", [], [], "op")
|
||||
|
||||
|
||||
# #endregion test_prepare_candidate_no_active_policy
|
||||
# [/DEF:test_prepare_candidate_no_active_policy:Function]
|
||||
|
||||
|
||||
# #endregion TestPreparationService
|
||||
# [/DEF:TestPreparationService:Module]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# #region TestReportBuilder [C:3] [TYPE Module] [SEMANTICS tests, clean-release, report-builder, counters]
|
||||
# @BRIEF Validate compliance report builder counter integrity and blocked-run constraints.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT blocked run requires at least one blocking violation.
|
||||
# @RELATION DEPENDS_ON -> [ReportBuilder]
|
||||
# [DEF:TestReportBuilder:Module]
|
||||
# @RELATION: [DEPENDS_ON] ->[ReportBuilder]
|
||||
# @COMPLEXITY: 3
|
||||
# @SEMANTICS: tests, clean-release, report-builder, counters
|
||||
# @PURPOSE: Validate compliance report builder counter integrity and blocked-run constraints.
|
||||
# @LAYER: Domain
|
||||
# @INVARIANT: blocked run requires at least one blocking violation.
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -20,9 +22,9 @@ from src.services.clean_release.report_builder import ComplianceReportBuilder
|
||||
from src.services.clean_release.repository import CleanReleaseRepository
|
||||
|
||||
|
||||
# #region _terminal_run [TYPE Function]
|
||||
# @BRIEF Build terminal/non-terminal run fixtures for report builder tests.
|
||||
# @RELATION BINDS_TO -> [TestReportBuilder]
|
||||
# [DEF:_terminal_run:Function]
|
||||
# @RELATION: BINDS_TO -> TestReportBuilder
|
||||
# @PURPOSE: Build terminal/non-terminal run fixtures for report builder tests.
|
||||
def _terminal_run(status: CheckFinalStatus) -> ComplianceCheckRun:
|
||||
return ComplianceCheckRun(
|
||||
check_run_id="check-1",
|
||||
@@ -37,12 +39,12 @@ def _terminal_run(status: CheckFinalStatus) -> ComplianceCheckRun:
|
||||
)
|
||||
|
||||
|
||||
# #endregion _terminal_run
|
||||
# [/DEF:_terminal_run:Function]
|
||||
|
||||
|
||||
# #region _blocking_violation [TYPE Function]
|
||||
# @BRIEF Build a blocking violation fixture for blocked report scenarios.
|
||||
# @RELATION BINDS_TO -> [TestReportBuilder]
|
||||
# [DEF:_blocking_violation:Function]
|
||||
# @RELATION: BINDS_TO -> TestReportBuilder
|
||||
# @PURPOSE: Build a blocking violation fixture for blocked report scenarios.
|
||||
def _blocking_violation() -> ComplianceViolation:
|
||||
return ComplianceViolation(
|
||||
violation_id="viol-1",
|
||||
@@ -56,12 +58,12 @@ def _blocking_violation() -> ComplianceViolation:
|
||||
)
|
||||
|
||||
|
||||
# #endregion _blocking_violation
|
||||
# [/DEF:_blocking_violation:Function]
|
||||
|
||||
|
||||
# #region test_report_builder_blocked_requires_blocking_violations [TYPE Function]
|
||||
# @BRIEF Verify BLOCKED run requires at least one blocking violation.
|
||||
# @RELATION BINDS_TO -> [TestReportBuilder]
|
||||
# [DEF:test_report_builder_blocked_requires_blocking_violations:Function]
|
||||
# @RELATION: BINDS_TO -> TestReportBuilder
|
||||
# @PURPOSE: Verify BLOCKED run requires at least one blocking violation.
|
||||
def test_report_builder_blocked_requires_blocking_violations():
|
||||
builder = ComplianceReportBuilder(CleanReleaseRepository())
|
||||
run = _terminal_run(CheckFinalStatus.BLOCKED)
|
||||
@@ -70,12 +72,12 @@ def test_report_builder_blocked_requires_blocking_violations():
|
||||
builder.build_report_payload(run, [])
|
||||
|
||||
|
||||
# #endregion test_report_builder_blocked_requires_blocking_violations
|
||||
# [/DEF:test_report_builder_blocked_requires_blocking_violations:Function]
|
||||
|
||||
|
||||
# #region test_report_builder_blocked_with_two_violations [TYPE Function]
|
||||
# @BRIEF Verify report builder generates conformant payload for a BLOCKED run with violations.
|
||||
# @RELATION BINDS_TO -> [TestReportBuilder]
|
||||
# [DEF:test_report_builder_blocked_with_two_violations:Function]
|
||||
# @RELATION: BINDS_TO -> TestReportBuilder
|
||||
# @PURPOSE: Verify report builder generates conformant payload for a BLOCKED run with violations.
|
||||
def test_report_builder_blocked_with_two_violations():
|
||||
builder = ComplianceReportBuilder(CleanReleaseRepository())
|
||||
run = _terminal_run(CheckFinalStatus.BLOCKED)
|
||||
@@ -93,12 +95,12 @@ def test_report_builder_blocked_with_two_violations():
|
||||
assert report.blocking_violations_count == 2
|
||||
|
||||
|
||||
# #endregion test_report_builder_blocked_with_two_violations
|
||||
# [/DEF:test_report_builder_blocked_with_two_violations:Function]
|
||||
|
||||
|
||||
# #region test_report_builder_counter_consistency [TYPE Function]
|
||||
# @BRIEF Verify violations counters remain consistent for blocking payload.
|
||||
# @RELATION BINDS_TO -> [TestReportBuilder]
|
||||
# [DEF:test_report_builder_counter_consistency:Function]
|
||||
# @RELATION: BINDS_TO -> TestReportBuilder
|
||||
# @PURPOSE: Verify violations counters remain consistent for blocking payload.
|
||||
def test_report_builder_counter_consistency():
|
||||
builder = ComplianceReportBuilder(CleanReleaseRepository())
|
||||
run = _terminal_run(CheckFinalStatus.BLOCKED)
|
||||
@@ -108,12 +110,12 @@ def test_report_builder_counter_consistency():
|
||||
assert report.blocking_violations_count == 1
|
||||
|
||||
|
||||
# #endregion test_report_builder_counter_consistency
|
||||
# [/DEF:test_report_builder_counter_consistency:Function]
|
||||
|
||||
|
||||
# #region test_missing_operator_summary [TYPE Function]
|
||||
# @BRIEF Validate non-terminal run prevents operator summary/report generation.
|
||||
# @RELATION BINDS_TO -> [TestReportBuilder]
|
||||
# [DEF:test_missing_operator_summary:Function]
|
||||
# @RELATION: BINDS_TO -> TestReportBuilder
|
||||
# @PURPOSE: Validate non-terminal run prevents operator summary/report generation.
|
||||
def test_missing_operator_summary():
|
||||
builder = ComplianceReportBuilder(CleanReleaseRepository())
|
||||
run = _terminal_run(CheckFinalStatus.RUNNING)
|
||||
@@ -124,5 +126,5 @@ def test_missing_operator_summary():
|
||||
assert "Cannot build report for non-terminal run" in str(exc.value)
|
||||
|
||||
|
||||
# #endregion test_missing_operator_summary
|
||||
# #endregion TestReportBuilder
|
||||
# [/DEF:test_missing_operator_summary:Function]
|
||||
# [/DEF:TestReportBuilder:Module]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# #region TestSourceIsolation [C:3] [TYPE Module] [SEMANTICS tests, clean-release, source-isolation, internal-only]
|
||||
# @BRIEF Verify internal source registry validation behavior.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT External endpoints always produce blocking violations.
|
||||
# @RELATION DEPENDS_ON -> [SourceIsolation]
|
||||
# [DEF:TestSourceIsolation:Module]
|
||||
# @RELATION: [DEPENDS_ON] ->[SourceIsolation]
|
||||
# @COMPLEXITY: 3
|
||||
# @SEMANTICS: tests, clean-release, source-isolation, internal-only
|
||||
# @PURPOSE: Verify internal source registry validation behavior.
|
||||
# @LAYER: Domain
|
||||
# @INVARIANT: External endpoints always produce blocking violations.
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -10,8 +12,8 @@ from src.models.clean_release import ResourceSourceEntry, ResourceSourceRegistry
|
||||
from src.services.clean_release.source_isolation import validate_internal_sources
|
||||
|
||||
|
||||
# #region _registry [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [TestSourceIsolation]
|
||||
# [DEF:_registry:Function]
|
||||
# @RELATION: BINDS_TO -> TestSourceIsolation
|
||||
def _registry() -> ResourceSourceRegistry:
|
||||
return ResourceSourceRegistry(
|
||||
registry_id="registry-internal-v1",
|
||||
@@ -38,12 +40,12 @@ def _registry() -> ResourceSourceRegistry:
|
||||
)
|
||||
|
||||
|
||||
# #endregion _registry
|
||||
# [/DEF:_registry:Function]
|
||||
|
||||
|
||||
# #region test_validate_internal_sources_all_internal_ok [TYPE Function]
|
||||
# @BRIEF Verify validate_internal_sources passes when all sources are internal and allowed.
|
||||
# @RELATION BINDS_TO -> [TestSourceIsolation]
|
||||
# [DEF:test_validate_internal_sources_all_internal_ok:Function]
|
||||
# @RELATION: BINDS_TO -> TestSourceIsolation
|
||||
# @PURPOSE: Verify validate_internal_sources passes when all sources are internal and allowed.
|
||||
def test_validate_internal_sources_all_internal_ok():
|
||||
result = validate_internal_sources(
|
||||
registry=_registry(),
|
||||
@@ -53,12 +55,12 @@ def test_validate_internal_sources_all_internal_ok():
|
||||
assert result["violations"] == []
|
||||
|
||||
|
||||
# #endregion test_validate_internal_sources_all_internal_ok
|
||||
# [/DEF:test_validate_internal_sources_all_internal_ok:Function]
|
||||
|
||||
|
||||
# #region test_validate_internal_sources_external_blocked [TYPE Function]
|
||||
# @BRIEF Verify validate_internal_sources blocks external sources when policy requires internal-only.
|
||||
# @RELATION BINDS_TO -> [TestSourceIsolation]
|
||||
# [DEF:test_validate_internal_sources_external_blocked:Function]
|
||||
# @RELATION: BINDS_TO -> TestSourceIsolation
|
||||
# @PURPOSE: Verify validate_internal_sources blocks external sources when policy requires internal-only.
|
||||
def test_validate_internal_sources_external_blocked():
|
||||
result = validate_internal_sources(
|
||||
registry=_registry(),
|
||||
@@ -70,5 +72,5 @@ def test_validate_internal_sources_external_blocked():
|
||||
assert result["violations"][0]["blocked_release"] is True
|
||||
|
||||
|
||||
# #endregion test_validate_internal_sources_external_blocked
|
||||
# #endregion TestSourceIsolation
|
||||
# [/DEF:test_validate_internal_sources_external_blocked:Function]
|
||||
# [/DEF:TestSourceIsolation:Module]
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# #region TestStages [C:3] [TYPE Module] [SEMANTICS tests, clean-release, compliance, stages]
|
||||
# @BRIEF Validate final status derivation logic from stage results.
|
||||
# @LAYER Domain
|
||||
# @RELATION DEPENDS_ON -> [ComplianceStages]
|
||||
# [DEF:TestStages:Module]
|
||||
# @RELATION: [DEPENDS_ON] ->[ComplianceStages]
|
||||
# @COMPLEXITY: 3
|
||||
# @SEMANTICS: tests, clean-release, compliance, stages
|
||||
# @PURPOSE: Validate final status derivation logic from stage results.
|
||||
# @LAYER: Domain
|
||||
|
||||
from src.models.clean_release import (
|
||||
CheckFinalStatus,
|
||||
@@ -12,9 +14,9 @@ from src.models.clean_release import (
|
||||
from src.services.clean_release.stages import derive_final_status, MANDATORY_STAGE_ORDER
|
||||
|
||||
|
||||
# #region test_derive_final_status_compliant [TYPE Function]
|
||||
# @BRIEF Verify derive_final_status returns compliant when all stages pass.
|
||||
# @RELATION BINDS_TO -> [TestStages]
|
||||
# [DEF:test_derive_final_status_compliant:Function]
|
||||
# @RELATION: BINDS_TO -> TestStages
|
||||
# @PURPOSE: Verify derive_final_status returns compliant when all stages pass.
|
||||
def test_derive_final_status_compliant():
|
||||
results = [
|
||||
CheckStageResult(stage=s, status=CheckStageStatus.PASS, details="ok")
|
||||
@@ -23,12 +25,12 @@ def test_derive_final_status_compliant():
|
||||
assert derive_final_status(results) == CheckFinalStatus.COMPLIANT
|
||||
|
||||
|
||||
# #endregion test_derive_final_status_compliant
|
||||
# [/DEF:test_derive_final_status_compliant:Function]
|
||||
|
||||
|
||||
# #region test_derive_final_status_blocked [TYPE Function]
|
||||
# @BRIEF Verify derive_final_status returns blocked when any stage fails.
|
||||
# @RELATION BINDS_TO -> [TestStages]
|
||||
# [DEF:test_derive_final_status_blocked:Function]
|
||||
# @RELATION: BINDS_TO -> TestStages
|
||||
# @PURPOSE: Verify derive_final_status returns blocked when any stage fails.
|
||||
def test_derive_final_status_blocked():
|
||||
results = [
|
||||
CheckStageResult(stage=s, status=CheckStageStatus.PASS, details="ok")
|
||||
@@ -38,12 +40,12 @@ def test_derive_final_status_blocked():
|
||||
assert derive_final_status(results) == CheckFinalStatus.BLOCKED
|
||||
|
||||
|
||||
# #endregion test_derive_final_status_blocked
|
||||
# [/DEF:test_derive_final_status_blocked:Function]
|
||||
|
||||
|
||||
# #region test_derive_final_status_failed_missing [TYPE Function]
|
||||
# @BRIEF Verify derive_final_status returns failed when required stages are missing.
|
||||
# @RELATION BINDS_TO -> [TestStages]
|
||||
# [DEF:test_derive_final_status_failed_missing:Function]
|
||||
# @RELATION: BINDS_TO -> TestStages
|
||||
# @PURPOSE: Verify derive_final_status returns failed when required stages are missing.
|
||||
def test_derive_final_status_failed_missing():
|
||||
results = [
|
||||
CheckStageResult(
|
||||
@@ -53,12 +55,12 @@ def test_derive_final_status_failed_missing():
|
||||
assert derive_final_status(results) == CheckFinalStatus.FAILED
|
||||
|
||||
|
||||
# #endregion test_derive_final_status_failed_missing
|
||||
# [/DEF:test_derive_final_status_failed_missing:Function]
|
||||
|
||||
|
||||
# #region test_derive_final_status_failed_skipped [TYPE Function]
|
||||
# @BRIEF Verify derive_final_status returns failed when critical stages are skipped.
|
||||
# @RELATION BINDS_TO -> [TestStages]
|
||||
# [DEF:test_derive_final_status_failed_skipped:Function]
|
||||
# @RELATION: BINDS_TO -> TestStages
|
||||
# @PURPOSE: Verify derive_final_status returns failed when critical stages are skipped.
|
||||
def test_derive_final_status_failed_skipped():
|
||||
results = [
|
||||
CheckStageResult(stage=s, status=CheckStageStatus.PASS, details="ok")
|
||||
@@ -68,5 +70,5 @@ def test_derive_final_status_failed_skipped():
|
||||
assert derive_final_status(results) == CheckFinalStatus.FAILED
|
||||
|
||||
|
||||
# #endregion test_derive_final_status_failed_skipped
|
||||
# #endregion TestStages
|
||||
# [/DEF:test_derive_final_status_failed_skipped:Function]
|
||||
# [/DEF:TestStages:Module]
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
# [DEF:ApprovalService:Module]
|
||||
# @COMPLEXITY: 5
|
||||
# @SEMANTICS: clean-release, approval, decision, lifecycle, gate
|
||||
# @PURPOSE: Enforce approval/rejection gates over immutable compliance reports.
|
||||
# #region ApprovalService [C:5] [TYPE Module] [SEMANTICS clean-release, approval, decision, lifecycle, gate]
|
||||
# @BRIEF Enforce approval/rejection gates over immutable compliance reports.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: [DEPENDS_ON] ->[RepositoryRelations]
|
||||
# @RELATION: [DEPENDS_ON] ->[CleanReleaseModels]
|
||||
# @RELATION: [DEPENDS_ON] ->[AuditService]
|
||||
# @RELATION DEPENDS_ON -> [RepositoryRelations]
|
||||
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
|
||||
# @RELATION DEPENDS_ON -> [AuditService]
|
||||
# @INVARIANT: Approval is allowed only for PASSED report bound to candidate; decisions are append-only.
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,8 +22,8 @@ from .repository import CleanReleaseRepository
|
||||
|
||||
# #region _get_or_init_decisions_store [TYPE Function]
|
||||
# @BRIEF Provide append-only in-memory storage for approval decisions.
|
||||
# @PRE repository is initialized.
|
||||
# @POST Returns mutable decision list attached to repository.
|
||||
# @PRE: repository is initialized.
|
||||
# @POST: Returns mutable decision list attached to repository.
|
||||
def _get_or_init_decisions_store(
|
||||
repository: CleanReleaseRepository,
|
||||
) -> List[ApprovalDecision]:
|
||||
@@ -41,8 +39,8 @@ def _get_or_init_decisions_store(
|
||||
|
||||
# #region _latest_decision_for_candidate [TYPE Function]
|
||||
# @BRIEF Resolve latest approval decision for candidate from append-only store.
|
||||
# @PRE candidate_id is non-empty.
|
||||
# @POST Returns latest ApprovalDecision or None.
|
||||
# @PRE: candidate_id is non-empty.
|
||||
# @POST: Returns latest ApprovalDecision or None.
|
||||
def _latest_decision_for_candidate(
|
||||
repository: CleanReleaseRepository, candidate_id: str
|
||||
) -> ApprovalDecision | None:
|
||||
@@ -62,8 +60,8 @@ def _latest_decision_for_candidate(
|
||||
|
||||
# #region _resolve_candidate_and_report [TYPE Function]
|
||||
# @BRIEF Validate candidate/report existence and ownership prior to decision persistence.
|
||||
# @PRE candidate_id and report_id are non-empty.
|
||||
# @POST Returns tuple(candidate, report); raises ApprovalGateError on contract violation.
|
||||
# @PRE: candidate_id and report_id are non-empty.
|
||||
# @POST: Returns tuple(candidate, report); raises ApprovalGateError on contract violation.
|
||||
def _resolve_candidate_and_report(
|
||||
repository: CleanReleaseRepository,
|
||||
*,
|
||||
@@ -89,8 +87,8 @@ def _resolve_candidate_and_report(
|
||||
|
||||
# #region approve_candidate [TYPE Function]
|
||||
# @BRIEF Persist immutable APPROVED decision and advance candidate lifecycle to APPROVED.
|
||||
# @PRE Candidate exists, report belongs to candidate, report final_status is PASSED, candidate not already APPROVED.
|
||||
# @POST Approval decision is appended and candidate transitions to APPROVED.
|
||||
# @PRE: Candidate exists, report belongs to candidate, report final_status is PASSED, candidate not already APPROVED.
|
||||
# @POST: Approval decision is appended and candidate transitions to APPROVED.
|
||||
def approve_candidate(
|
||||
*,
|
||||
repository: CleanReleaseRepository,
|
||||
@@ -165,8 +163,8 @@ def approve_candidate(
|
||||
|
||||
# #region reject_candidate [TYPE Function]
|
||||
# @BRIEF Persist immutable REJECTED decision without promoting candidate lifecycle.
|
||||
# @PRE Candidate exists and report belongs to candidate.
|
||||
# @POST Rejected decision is appended; candidate lifecycle is unchanged.
|
||||
# @PRE: Candidate exists and report belongs to candidate.
|
||||
# @POST: Rejected decision is appended; candidate lifecycle is unchanged.
|
||||
def reject_candidate(
|
||||
*,
|
||||
repository: CleanReleaseRepository,
|
||||
@@ -210,4 +208,4 @@ def reject_candidate(
|
||||
|
||||
# #endregion reject_candidate
|
||||
|
||||
# [/DEF:backend.src.services.clean_release.approval_service:Module]
|
||||
# #endregion backend.src.services.clean_release.approval_service
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
# [DEF:ArtifactCatalogLoader:Module]
|
||||
# @COMPLEXITY: 3
|
||||
# @SEMANTICS: clean-release, artifacts, bootstrap, json, tui
|
||||
# @PURPOSE: Load bootstrap artifact catalogs for clean release real-mode flows.
|
||||
# #region ArtifactCatalogLoader [C:3] [TYPE Module] [SEMANTICS clean-release, artifacts, bootstrap, json, tui]
|
||||
# @BRIEF Load bootstrap artifact catalogs for clean release real-mode flows.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: [DEPENDS_ON] ->[CleanReleaseModels]
|
||||
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
|
||||
# @INVARIANT: Artifact catalog must produce deterministic CandidateArtifact entries with required identity and checksum fields.
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,8 +15,8 @@ from ...models.clean_release import CandidateArtifact
|
||||
|
||||
# #region load_bootstrap_artifacts [TYPE Function]
|
||||
# @BRIEF Parse artifact catalog JSON into CandidateArtifact models for TUI/bootstrap flows.
|
||||
# @PRE path points to readable JSON file; payload is list[artifact] or {"artifacts": list[artifact]}.
|
||||
# @POST Returns non-mutated CandidateArtifact models with required fields populated.
|
||||
# @PRE: path points to readable JSON file; payload is list[artifact] or {"artifacts": list[artifact]}.
|
||||
# @POST: Returns non-mutated CandidateArtifact models with required fields populated.
|
||||
def load_bootstrap_artifacts(path: str, candidate_id: str) -> List[CandidateArtifact]:
|
||||
if not path or not path.strip():
|
||||
return []
|
||||
@@ -104,4 +102,4 @@ def load_bootstrap_artifacts(path: str, candidate_id: str) -> List[CandidateArti
|
||||
|
||||
# #endregion load_bootstrap_artifacts
|
||||
|
||||
# [/DEF:backend.src.services.clean_release.artifact_catalog_loader:Module]
|
||||
# #endregion backend.src.services.clean_release.artifact_catalog_loader
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
# [DEF:AuditService:Module]
|
||||
# @COMPLEXITY: 3
|
||||
# @SEMANTICS: clean-release, audit, lifecycle, logging
|
||||
# @PURPOSE: Provide lightweight audit hooks for clean release preparation/check/report lifecycle.
|
||||
# #region AuditService [C:3] [TYPE Module] [SEMANTICS clean-release, audit, lifecycle, logging]
|
||||
# @BRIEF Provide lightweight audit hooks for clean release preparation/check/report lifecycle.
|
||||
# @LAYER: Infra
|
||||
# @RELATION: [DEPENDS_ON] ->[LoggerModule]
|
||||
# @RELATION DEPENDS_ON -> [LoggerModule]
|
||||
# @INVARIANT: Audit hooks are append-only log actions.
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -116,4 +114,4 @@ def audit_report(
|
||||
)
|
||||
|
||||
|
||||
# [/DEF:backend.src.services.clean_release.audit_service:Module]
|
||||
# #endregion backend.src.services.clean_release.audit_service
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# #region candidate_service [C:5] [TYPE Module] [SEMANTICS clean-release, candidate, artifacts, lifecycle, validation]
|
||||
# @BRIEF Register release candidates with validated artifacts and advance lifecycle through legal transitions.
|
||||
# @LAYER Domain
|
||||
# @PRE candidate_id must be unique; artifacts input must be non-empty and valid.
|
||||
# @POST candidate and artifacts are persisted; candidate transitions DRAFT -> PREPARED only.
|
||||
# @INVARIANT Candidate lifecycle transitions are delegated to domain guard logic.
|
||||
# @RELATION DEPENDS_ON -> [backend.src.services.clean_release.repository]
|
||||
# @RELATION DEPENDS_ON -> [backend.src.models.clean_release]
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> backend.src.services.clean_release.repository
|
||||
# @RELATION DEPENDS_ON -> backend.src.models.clean_release
|
||||
# @PRE: candidate_id must be unique; artifacts input must be non-empty and valid.
|
||||
# @POST: candidate and artifacts are persisted; candidate transitions DRAFT -> PREPARED only.
|
||||
# @INVARIANT: Candidate lifecycle transitions are delegated to domain guard logic.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -19,8 +19,8 @@ from .repository import CleanReleaseRepository
|
||||
|
||||
# #region _validate_artifacts [TYPE Function]
|
||||
# @BRIEF Validate raw artifact payload list for required fields and shape.
|
||||
# @PRE artifacts payload is provided by caller.
|
||||
# @POST Returns normalized artifact list or raises ValueError.
|
||||
# @PRE: artifacts payload is provided by caller.
|
||||
# @POST: Returns normalized artifact list or raises ValueError.
|
||||
def _validate_artifacts(artifacts: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
normalized = list(artifacts)
|
||||
if not normalized:
|
||||
@@ -47,8 +47,8 @@ def _validate_artifacts(artifacts: Iterable[Dict[str, Any]]) -> List[Dict[str, A
|
||||
|
||||
# #region register_candidate [TYPE Function]
|
||||
# @BRIEF Register a candidate and persist its artifacts with legal lifecycle transition.
|
||||
# @PRE candidate_id must be unique and artifacts must pass validation.
|
||||
# @POST Candidate exists in repository with PREPARED status and artifacts persisted.
|
||||
# @PRE: candidate_id must be unique and artifacts must pass validation.
|
||||
# @POST: Candidate exists in repository with PREPARED status and artifacts persisted.
|
||||
def register_candidate(
|
||||
repository: CleanReleaseRepository,
|
||||
candidate_id: str,
|
||||
@@ -102,4 +102,4 @@ def register_candidate(
|
||||
return candidate
|
||||
# #endregion register_candidate
|
||||
|
||||
# #endregion candidate_service
|
||||
# #endregion backend.src.services.clean_release.candidate_service
|
||||
@@ -1,15 +1,15 @@
|
||||
# #region ComplianceExecutionService [C:5] [TYPE Module] [SEMANTICS clean-release, compliance, execution, stages, immutable-evidence]
|
||||
# @BRIEF Create and execute compliance runs with trusted snapshots, deterministic stages, violations and immutable report persistence.
|
||||
# @LAYER Domain
|
||||
# @PRE Repository adapters, trusted policy resolution, and deterministic stage implementations are available for the run request.
|
||||
# @POST Candidate-scoped compliance runs persist stage evidence, terminal status, and immutable report artifacts when execution succeeds.
|
||||
# @SIDE_EFFECT Persists runs, stage results, violations, and reports through repository adapters and audit helpers.
|
||||
# @DATA_CONTRACT Input[candidate_id, requested_by, manifest_id?, policy snapshots, stage results] -> Output[ComplianceExecutionResult]
|
||||
# @INVARIANT A run binds to exactly one candidate/manifest/policy/registry snapshot set.
|
||||
# @RELATION DEPENDS_ON -> [RepositoryRelations]
|
||||
# @RELATION DEPENDS_ON -> [PolicyResolutionService]
|
||||
# @RELATION DEPENDS_ON -> [ComplianceStages]
|
||||
# @RELATION DEPENDS_ON -> [ReportBuilder]
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> RepositoryRelations
|
||||
# @RELATION DEPENDS_ON -> PolicyResolutionService
|
||||
# @RELATION DEPENDS_ON -> ComplianceStages
|
||||
# @RELATION DEPENDS_ON -> ReportBuilder
|
||||
# @PRE: Repository adapters, trusted policy resolution, and deterministic stage implementations are available for the run request.
|
||||
# @POST: Candidate-scoped compliance runs persist stage evidence, terminal status, and immutable report artifacts when execution succeeds.
|
||||
# @SIDE_EFFECT: Persists runs, stage results, violations, and reports through repository adapters and audit helpers.
|
||||
# @DATA_CONTRACT: Input[candidate_id, requested_by, manifest_id?, policy snapshots, stage results] -> Output[ComplianceExecutionResult]
|
||||
# @INVARIANT: A run binds to exactly one candidate/manifest/policy/registry snapshot set.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -54,10 +54,10 @@ class ComplianceExecutionResult:
|
||||
|
||||
# #region ComplianceExecutionService [TYPE Class]
|
||||
# @BRIEF Execute clean-release compliance lifecycle over trusted snapshots and immutable evidence.
|
||||
# @PRE Database session active, candidate registered
|
||||
# @POST Returns ComplianceReport with pass/fail status and violation details
|
||||
# @SIDE_EFFECT Updates compliance status in database, logs violations
|
||||
# @DATA_CONTRACT ComplianceCheckResult, ComplianceReport, Violation
|
||||
# @PRE: Database session active, candidate registered
|
||||
# @POST: Returns ComplianceReport with pass/fail status and violation details
|
||||
# @SIDE_EFFECT: Updates compliance status in database, logs violations
|
||||
# @DATA_CONTRACT: ComplianceCheckResult, ComplianceReport, Violation
|
||||
class ComplianceExecutionService:
|
||||
TASK_PLUGIN_ID = "clean-release-compliance"
|
||||
|
||||
@@ -73,10 +73,10 @@ class ComplianceExecutionService:
|
||||
self.stages = list(stages) if stages is not None else build_default_stages()
|
||||
self.report_builder = ComplianceReportBuilder(repository)
|
||||
|
||||
# #region _resolve_manifest [TYPE Function]
|
||||
# @BRIEF Resolve explicit manifest or fallback to latest candidate manifest.
|
||||
# @PRE candidate exists.
|
||||
# @POST Returns manifest snapshot or raises ComplianceRunError.
|
||||
# [DEF:_resolve_manifest:Function]
|
||||
# @PURPOSE: Resolve explicit manifest or fallback to latest candidate manifest.
|
||||
# @PRE: candidate exists.
|
||||
# @POST: Returns manifest snapshot or raises ComplianceRunError.
|
||||
def _resolve_manifest(
|
||||
self, candidate_id: str, manifest_id: Optional[str]
|
||||
) -> DistributionManifest:
|
||||
@@ -99,29 +99,29 @@ class ComplianceExecutionService:
|
||||
manifests, key=lambda item: item.manifest_version, reverse=True
|
||||
)[0]
|
||||
|
||||
# #endregion _resolve_manifest
|
||||
# [/DEF:_resolve_manifest:Function]
|
||||
|
||||
# #region _persist_stage_run [TYPE Function]
|
||||
# @BRIEF Persist stage run if repository supports stage records.
|
||||
# @POST Stage run is persisted when adapter is available, otherwise no-op.
|
||||
# [DEF:_persist_stage_run:Function]
|
||||
# @PURPOSE: Persist stage run if repository supports stage records.
|
||||
# @POST: Stage run is persisted when adapter is available, otherwise no-op.
|
||||
def _persist_stage_run(self, stage_run: ComplianceStageRun) -> None:
|
||||
self.repository.save_stage_run(stage_run)
|
||||
|
||||
# #endregion _persist_stage_run
|
||||
# [/DEF:_persist_stage_run:Function]
|
||||
|
||||
# #region _persist_violations [TYPE Function]
|
||||
# @BRIEF Persist stage violations via repository adapters.
|
||||
# @POST Violations are appended to repository evidence store.
|
||||
# [DEF:_persist_violations:Function]
|
||||
# @PURPOSE: Persist stage violations via repository adapters.
|
||||
# @POST: Violations are appended to repository evidence store.
|
||||
def _persist_violations(self, violations: List[ComplianceViolation]) -> None:
|
||||
for violation in violations:
|
||||
self.repository.save_violation(violation)
|
||||
|
||||
# #endregion _persist_violations
|
||||
# [/DEF:_persist_violations:Function]
|
||||
|
||||
# #region execute_run [TYPE Function]
|
||||
# @BRIEF Execute compliance run stages and finalize immutable report on terminal success.
|
||||
# @PRE candidate exists and trusted policy/registry snapshots are resolvable.
|
||||
# @POST Run and evidence are persisted; report exists for SUCCEEDED runs.
|
||||
# [DEF:execute_run:Function]
|
||||
# @PURPOSE: Execute compliance run stages and finalize immutable report on terminal success.
|
||||
# @PRE: candidate exists and trusted policy/registry snapshots are resolvable.
|
||||
# @POST: Run and evidence are persisted; report exists for SUCCEEDED runs.
|
||||
def execute_run(
|
||||
self,
|
||||
*,
|
||||
@@ -223,7 +223,7 @@ class ComplianceExecutionService:
|
||||
violations=violations,
|
||||
)
|
||||
|
||||
# #endregion execute_run
|
||||
# [/DEF:execute_run:Function]
|
||||
|
||||
|
||||
# #endregion ComplianceExecutionService
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# #region ComplianceOrchestrator [C:5] [TYPE Module] [SEMANTICS clean-release, orchestrator, compliance-gate, stages]
|
||||
# @BRIEF Execute mandatory clean compliance stages and produce final COMPLIANT/BLOCKED/FAILED outcome.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT COMPLIANT is impossible when any mandatory stage fails.
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [ComplianceStages]
|
||||
# @RELATION DEPENDS_ON -> [RepositoryRelations]
|
||||
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
|
||||
# @INVARIANT: COMPLIANT is impossible when any mandatory stage fails.
|
||||
# @TEST_CONTRACT: ComplianceCheckRun -> ComplianceCheckRun
|
||||
# @TEST_FIXTURE: compliant_candidate -> file:backend/tests/fixtures/clean_release/fixtures_clean_release.json
|
||||
# @TEST_EDGE: stage_failure_blocks_release -> Mandatory stage returns FAIL and final status becomes BLOCKED
|
||||
@@ -44,24 +44,24 @@ from ...core.logger import belief_scope, logger
|
||||
# #region CleanComplianceOrchestrator [TYPE Class]
|
||||
# @BRIEF Coordinate clean-release compliance verification stages.
|
||||
class CleanComplianceOrchestrator:
|
||||
# #region __init__ [TYPE Function]
|
||||
# @BRIEF Bind repository dependency used for orchestrator persistence and lookups.
|
||||
# @PRE repository is a valid CleanReleaseRepository instance with required methods.
|
||||
# @POST self.repository is assigned and used by all orchestration steps.
|
||||
# @SIDE_EFFECT Stores repository reference on orchestrator instance.
|
||||
# @DATA_CONTRACT Input -> CleanReleaseRepository, Output -> None
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Bind repository dependency used for orchestrator persistence and lookups.
|
||||
# @PRE: repository is a valid CleanReleaseRepository instance with required methods.
|
||||
# @POST: self.repository is assigned and used by all orchestration steps.
|
||||
# @SIDE_EFFECT: Stores repository reference on orchestrator instance.
|
||||
# @DATA_CONTRACT: Input -> CleanReleaseRepository, Output -> None
|
||||
def __init__(self, repository: CleanReleaseRepository):
|
||||
with belief_scope("CleanComplianceOrchestrator.__init__"):
|
||||
self.repository = repository
|
||||
|
||||
# #endregion __init__
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# #region start_check_run [TYPE Function]
|
||||
# @BRIEF Initiate a new compliance run session.
|
||||
# @PRE candidate_id and policy_id are provided; legacy callers may omit persisted manifest/policy records.
|
||||
# @POST Returns initialized ComplianceRun in RUNNING state persisted in repository.
|
||||
# @SIDE_EFFECT Reads manifest/policy when present and writes new ComplianceRun via repository.save_check_run.
|
||||
# @DATA_CONTRACT Input -> (candidate_id:str, policy_id:str, requested_by:str, manifest_id:str|None), Output -> ComplianceRun
|
||||
# [DEF:start_check_run:Function]
|
||||
# @PURPOSE: Initiate a new compliance run session.
|
||||
# @PRE: candidate_id and policy_id are provided; legacy callers may omit persisted manifest/policy records.
|
||||
# @POST: Returns initialized ComplianceRun in RUNNING state persisted in repository.
|
||||
# @SIDE_EFFECT: Reads manifest/policy when present and writes new ComplianceRun via repository.save_check_run.
|
||||
# @DATA_CONTRACT: Input -> (candidate_id:str, policy_id:str, requested_by:str, manifest_id:str|None), Output -> ComplianceRun
|
||||
def start_check_run(
|
||||
self,
|
||||
candidate_id: str,
|
||||
@@ -149,14 +149,14 @@ class CleanComplianceOrchestrator:
|
||||
)
|
||||
return self.repository.save_check_run(check_run)
|
||||
|
||||
# #endregion start_check_run
|
||||
# [/DEF:start_check_run:Function]
|
||||
|
||||
# #region execute_stages [TYPE Function]
|
||||
# @BRIEF Execute or accept compliance stage outcomes and set intermediate/final check-run status fields.
|
||||
# @PRE check_run exists and references candidate/policy/registry/manifest identifiers resolvable by repository.
|
||||
# @POST Returns persisted ComplianceRun with status FAILED on missing dependencies, otherwise SUCCEEDED with final_status set.
|
||||
# @SIDE_EFFECT Reads candidate/policy/registry/manifest and persists updated check_run.
|
||||
# @DATA_CONTRACT Input -> (check_run:ComplianceRun, forced_results:Optional[List[ComplianceStageRun]]), Output -> ComplianceRun
|
||||
# [DEF:execute_stages:Function]
|
||||
# @PURPOSE: Execute or accept compliance stage outcomes and set intermediate/final check-run status fields.
|
||||
# @PRE: check_run exists and references candidate/policy/registry/manifest identifiers resolvable by repository.
|
||||
# @POST: Returns persisted ComplianceRun with status FAILED on missing dependencies, otherwise SUCCEEDED with final_status set.
|
||||
# @SIDE_EFFECT: Reads candidate/policy/registry/manifest and persists updated check_run.
|
||||
# @DATA_CONTRACT: Input -> (check_run:ComplianceRun, forced_results:Optional[List[ComplianceStageRun]]), Output -> ComplianceRun
|
||||
def execute_stages(
|
||||
self,
|
||||
check_run: ComplianceRun,
|
||||
@@ -211,14 +211,14 @@ class CleanComplianceOrchestrator:
|
||||
|
||||
return self.repository.save_check_run(check_run)
|
||||
|
||||
# #endregion execute_stages
|
||||
# [/DEF:execute_stages:Function]
|
||||
|
||||
# #region finalize_run [TYPE Function]
|
||||
# @BRIEF Finalize run status based on cumulative stage results.
|
||||
# @PRE check_run was started and may already contain a derived final_status from stage execution.
|
||||
# @POST Returns persisted ComplianceRun in SUCCEEDED status with final_status guaranteed non-empty.
|
||||
# @SIDE_EFFECT Mutates check_run terminal fields and persists via repository.save_check_run.
|
||||
# @DATA_CONTRACT Input -> ComplianceRun, Output -> ComplianceRun
|
||||
# [DEF:finalize_run:Function]
|
||||
# @PURPOSE: Finalize run status based on cumulative stage results.
|
||||
# @PRE: check_run was started and may already contain a derived final_status from stage execution.
|
||||
# @POST: Returns persisted ComplianceRun in SUCCEEDED status with final_status guaranteed non-empty.
|
||||
# @SIDE_EFFECT: Mutates check_run terminal fields and persists via repository.save_check_run.
|
||||
# @DATA_CONTRACT: Input -> ComplianceRun, Output -> ComplianceRun
|
||||
def finalize_run(self, check_run: ComplianceRun) -> ComplianceRun:
|
||||
with belief_scope("finalize_run"):
|
||||
if check_run.status == RunStatus.FAILED:
|
||||
@@ -238,7 +238,7 @@ class CleanComplianceOrchestrator:
|
||||
check_run.finished_at = datetime.now(timezone.utc)
|
||||
return self.repository.save_check_run(check_run)
|
||||
|
||||
# #endregion finalize_run
|
||||
# [/DEF:finalize_run:Function]
|
||||
|
||||
|
||||
# #endregion CleanComplianceOrchestrator
|
||||
@@ -246,10 +246,10 @@ class CleanComplianceOrchestrator:
|
||||
|
||||
# #region run_check_legacy [TYPE Function]
|
||||
# @BRIEF Legacy wrapper for compatibility with previous orchestrator call style.
|
||||
# @PRE repository and identifiers are valid and resolvable by orchestrator dependencies.
|
||||
# @POST Returns finalized ComplianceRun produced by orchestrator start->execute->finalize sequence.
|
||||
# @SIDE_EFFECT Reads/writes compliance entities through repository during orchestrator calls.
|
||||
# @DATA_CONTRACT Input -> (repository:CleanReleaseRepository, candidate_id:str, policy_id:str, requested_by:str, manifest_id:str), Output -> ComplianceRun
|
||||
# @PRE: repository and identifiers are valid and resolvable by orchestrator dependencies.
|
||||
# @POST: Returns finalized ComplianceRun produced by orchestrator start->execute->finalize sequence.
|
||||
# @SIDE_EFFECT: Reads/writes compliance entities through repository during orchestrator calls.
|
||||
# @DATA_CONTRACT: Input -> (repository:CleanReleaseRepository, candidate_id:str, policy_id:str, requested_by:str, manifest_id:str), Output -> ComplianceRun
|
||||
def run_check_legacy(
|
||||
repository: CleanReleaseRepository,
|
||||
candidate_id: str,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# #region DemoDataService [C:3] [TYPE Module] [SEMANTICS clean-release, demo-mode, namespace, isolation, repository]
|
||||
# @BRIEF Provide deterministic namespace helpers and isolated in-memory repository creation for demo and real modes.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT Demo and real namespaces must never collide for generated physical identifiers.
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [RepositoryRelations]
|
||||
# @INVARIANT: Demo and real namespaces must never collide for generated physical identifiers.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,8 +11,8 @@ from .repository import CleanReleaseRepository
|
||||
|
||||
# #region resolve_namespace [TYPE Function]
|
||||
# @BRIEF Resolve canonical clean-release namespace for requested mode.
|
||||
# @PRE mode is a non-empty string identifying runtime mode.
|
||||
# @POST Returns deterministic namespace key for demo/real separation.
|
||||
# @PRE: mode is a non-empty string identifying runtime mode.
|
||||
# @POST: Returns deterministic namespace key for demo/real separation.
|
||||
def resolve_namespace(mode: str) -> str:
|
||||
normalized = (mode or "").strip().lower()
|
||||
if normalized == "demo":
|
||||
@@ -25,8 +25,8 @@ def resolve_namespace(mode: str) -> str:
|
||||
|
||||
# #region build_namespaced_id [TYPE Function]
|
||||
# @BRIEF Build storage-safe physical identifier under mode namespace.
|
||||
# @PRE namespace and logical_id are non-empty strings.
|
||||
# @POST Returns deterministic "{namespace}::{logical_id}" identifier.
|
||||
# @PRE: namespace and logical_id are non-empty strings.
|
||||
# @POST: Returns deterministic "{namespace}::{logical_id}" identifier.
|
||||
def build_namespaced_id(namespace: str, logical_id: str) -> str:
|
||||
if not namespace or not namespace.strip():
|
||||
raise ValueError("namespace must be non-empty")
|
||||
@@ -40,8 +40,8 @@ def build_namespaced_id(namespace: str, logical_id: str) -> str:
|
||||
|
||||
# #region create_isolated_repository [TYPE Function]
|
||||
# @BRIEF Create isolated in-memory repository instance for selected mode namespace.
|
||||
# @PRE mode is a valid runtime mode marker.
|
||||
# @POST Returns repository instance tagged with namespace metadata.
|
||||
# @PRE: mode is a valid runtime mode marker.
|
||||
# @POST: Returns repository instance tagged with namespace metadata.
|
||||
def create_isolated_repository(mode: str) -> CleanReleaseRepository:
|
||||
namespace = resolve_namespace(mode)
|
||||
repository = CleanReleaseRepository()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# #region clean_release_dto [C:3] [TYPE Module]
|
||||
# @BRIEF Data Transfer Objects for clean release compliance subsystem.
|
||||
# @LAYER Application
|
||||
# @RELATION DEPENDS_ON -> [pydantic]
|
||||
# @LAYER: Application
|
||||
# @RELATION DEPENDS_ON -> pydantic
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any
|
||||
@@ -82,4 +82,4 @@ class CandidateOverviewDTO(BaseModel):
|
||||
latest_publication_id: Optional[str] = None
|
||||
latest_publication_status: Optional[str] = None
|
||||
|
||||
# #endregion clean_release_dto
|
||||
# #endregion clean_release_dto
|
||||
@@ -1,7 +1,7 @@
|
||||
# #region clean_release_enums [C:3] [TYPE Module]
|
||||
# @BRIEF Canonical enums for clean release lifecycle and compliance.
|
||||
# @LAYER Domain
|
||||
# @RELATION DEPENDS_ON -> [enum]
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> enum
|
||||
|
||||
from enum import Enum
|
||||
|
||||
@@ -69,4 +69,4 @@ class ViolationCategory(str, Enum):
|
||||
MANIFEST_CONSISTENCY = "MANIFEST_CONSISTENCY"
|
||||
EXTERNAL_ENDPOINT = "EXTERNAL_ENDPOINT"
|
||||
|
||||
# #endregion clean_release_enums
|
||||
# #endregion clean_release_enums
|
||||
@@ -1,7 +1,7 @@
|
||||
# #region clean_release_exceptions [C:3] [TYPE Module]
|
||||
# @BRIEF Domain exceptions for clean release compliance subsystem.
|
||||
# @LAYER Domain
|
||||
# @RELATION DEPENDS_ON -> [Exception]
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> Exception
|
||||
|
||||
class CleanReleaseError(Exception):
|
||||
"""Base exception for clean release subsystem."""
|
||||
@@ -35,4 +35,4 @@ class PublicationGateError(CleanReleaseError):
|
||||
"""Raised when publication requirements are not met."""
|
||||
pass
|
||||
|
||||
# #endregion clean_release_exceptions
|
||||
# #endregion clean_release_exceptions
|
||||
@@ -1,7 +1,7 @@
|
||||
# #region clean_release_facade [C:3] [TYPE Module]
|
||||
# @BRIEF Unified entry point for clean release operations.
|
||||
# @LAYER Application
|
||||
# @RELATION DEPENDS_ON -> [ComplianceOrchestrator]
|
||||
# @LAYER: Application
|
||||
# @RELATION DEPENDS_ON -> ComplianceOrchestrator
|
||||
|
||||
from typing import List, Optional
|
||||
from src.services.clean_release.repositories import (
|
||||
@@ -119,4 +119,4 @@ class CleanReleaseFacade:
|
||||
candidates = self.candidate_repo.list_all()
|
||||
return [self.get_candidate_overview(c.id) for c in candidates]
|
||||
|
||||
# #endregion clean_release_facade
|
||||
# #endregion clean_release_facade
|
||||
@@ -1,8 +1,8 @@
|
||||
# #region ManifestBuilder [C:3] [TYPE Module] [SEMANTICS clean-release, manifest, deterministic-hash, summary]
|
||||
# @BRIEF Build deterministic distribution manifest from classified artifact input.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT Equal semantic artifact sets produce identical deterministic hash values.
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
|
||||
# @INVARIANT: Equal semantic artifact sets produce identical deterministic hash values.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -54,8 +54,8 @@ def _stable_hash_payload(
|
||||
|
||||
# #region build_distribution_manifest [TYPE Function]
|
||||
# @BRIEF Build DistributionManifest with deterministic hash and validated counters.
|
||||
# @PRE artifacts list contains normalized classification values.
|
||||
# @POST Returns DistributionManifest with summary counts matching items cardinality.
|
||||
# @PRE: artifacts list contains normalized classification values.
|
||||
# @POST: Returns DistributionManifest with summary counts matching items cardinality.
|
||||
def build_distribution_manifest(
|
||||
manifest_id: str,
|
||||
candidate_id: str,
|
||||
@@ -111,8 +111,8 @@ def build_distribution_manifest(
|
||||
|
||||
# #region build_manifest [TYPE Function]
|
||||
# @BRIEF Legacy compatibility wrapper for old manifest builder import paths.
|
||||
# @PRE Same as build_distribution_manifest.
|
||||
# @POST Returns DistributionManifest produced by canonical builder.
|
||||
# @PRE: Same as build_distribution_manifest.
|
||||
# @POST: Returns DistributionManifest produced by canonical builder.
|
||||
def build_manifest(
|
||||
manifest_id: str,
|
||||
candidate_id: str,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# #region ManifestService [C:5] [TYPE Module] [SEMANTICS clean-release, manifest, versioning, immutability, lifecycle]
|
||||
# @BRIEF Build immutable distribution manifests with deterministic digest and version increment.
|
||||
# @LAYER Domain
|
||||
# @PRE Candidate exists and is PREPARED or MANIFEST_BUILT; artifacts are present.
|
||||
# @POST New immutable manifest is persisted with incremented version and deterministic digest.
|
||||
# @SIDE_EFFECT May modify manifest state during processing
|
||||
# @DATA_CONTRACT Manifest -> ManifestRecord; Candidate -> ManifestRecord
|
||||
# @INVARIANT Existing manifests are never mutated.
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [RepositoryRelations]
|
||||
# @RELATION DEPENDS_ON -> [ManifestBuilder]
|
||||
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
|
||||
# @PRE: Candidate exists and is PREPARED or MANIFEST_BUILT; artifacts are present.
|
||||
# @POST: New immutable manifest is persisted with incremented version and deterministic digest.
|
||||
# @INVARIANT: Existing manifests are never mutated.
|
||||
# @SIDE_EFFECT: May modify manifest state during processing
|
||||
# @DATA_CONTRACT: Manifest -> ManifestRecord; Candidate -> ManifestRecord
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -22,8 +22,8 @@ from .repository import CleanReleaseRepository
|
||||
|
||||
# #region build_manifest_snapshot [TYPE Function]
|
||||
# @BRIEF Create a new immutable manifest version for a candidate.
|
||||
# @PRE Candidate is prepared, artifacts are available, candidate_id is valid.
|
||||
# @POST Returns persisted DistributionManifest with monotonically incremented version.
|
||||
# @PRE: Candidate is prepared, artifacts are available, candidate_id is valid.
|
||||
# @POST: Returns persisted DistributionManifest with monotonically incremented version.
|
||||
def build_manifest_snapshot(
|
||||
repository: CleanReleaseRepository,
|
||||
candidate_id: str,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# #region clean_release_mappers [C:3] [TYPE Module]
|
||||
# @BRIEF Map between domain entities (SQLAlchemy models) and DTOs.
|
||||
# @LAYER Application
|
||||
# @RELATION DEPENDS_ON -> [clean_release_dto]
|
||||
# @LAYER: Application
|
||||
# @RELATION DEPENDS_ON -> clean_release_dto
|
||||
|
||||
from typing import List
|
||||
from src.models.clean_release import (
|
||||
@@ -64,4 +64,4 @@ def map_report_to_dto(report: ComplianceReport) -> ReportDTO:
|
||||
generated_at=report.generated_at
|
||||
)
|
||||
|
||||
# #endregion clean_release_mappers
|
||||
# #endregion clean_release_mappers
|
||||
@@ -1,13 +1,13 @@
|
||||
# #region PolicyEngine [C:5] [TYPE Module] [SEMANTICS clean-release, policy, classification, source-isolation]
|
||||
# @BRIEF Evaluate artifact/source policies for enterprise clean profile with deterministic outcomes.
|
||||
# @LAYER Domain
|
||||
# @PRE PolicyRepository is accessible
|
||||
# @POST PolicyDecision returned with approval status
|
||||
# @SIDE_EFFECT Read-only policy evaluation; no state changes
|
||||
# @DATA_CONTRACT Candidate -> PolicyDecision
|
||||
# @INVARIANT Enterprise-clean policy always treats non-registry sources as violations.
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
|
||||
# @RELATION DEPENDS_ON -> [LoggerModule]
|
||||
# @INVARIANT: Enterprise-clean policy always treats non-registry sources as violations.
|
||||
# @DATA_CONTRACT: Candidate -> PolicyDecision
|
||||
# @PRE: PolicyRepository is accessible
|
||||
# @POST: PolicyDecision returned with approval status
|
||||
# @SIDE_EFFECT: Read-only policy evaluation; no state changes
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -36,8 +36,8 @@ class SourceValidationResult:
|
||||
|
||||
|
||||
# #region CleanPolicyEngine [TYPE Class]
|
||||
# @PRE Active policy exists and is internally consistent.
|
||||
# @POST Deterministic classification and source validation are available.
|
||||
# @PRE: Active policy exists and is internally consistent.
|
||||
# @POST: Deterministic classification and source validation are available.
|
||||
# @TEST_CONTRACT: CandidateEvaluationInput -> PolicyValidationResult|SourceValidationResult
|
||||
# @TEST_SCENARIO: policy_valid -> Enterprise clean policy with matching registry returns ok=True
|
||||
# @TEST_FIXTURE: policy_enterprise_clean -> file:backend/tests/fixtures/clean_release/fixtures_clean_release.json
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# #region PolicyResolutionService [C:5] [TYPE Module] [SEMANTICS clean-release, policy, registry, trusted-resolution, immutable-snapshots]
|
||||
# @BRIEF Resolve trusted policy and registry snapshots from ConfigManager without runtime overrides.
|
||||
# @LAYER Domain
|
||||
# @PRE PolicyRepository and Manifest are available
|
||||
# @POST ResolutionResult with matched policies
|
||||
# @SIDE_EFFECT Read-only policy evaluation; logs resolution decisions
|
||||
# @DATA_CONTRACT PolicyRequest -> ResolutionResult
|
||||
# @INVARIANT Trusted snapshot resolution is based only on ConfigManager active identifiers.
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [ConfigManager]
|
||||
# @RELATION DEPENDS_ON -> [RepositoryRelations]
|
||||
# @RELATION DEPENDS_ON -> [clean_release_exceptions]
|
||||
# @INVARIANT: Trusted snapshot resolution is based only on ConfigManager active identifiers.
|
||||
# @DATA_CONTRACT: PolicyRequest -> ResolutionResult
|
||||
# @PRE: PolicyRepository and Manifest are available
|
||||
# @POST: ResolutionResult with matched policies
|
||||
# @SIDE_EFFECT: Read-only policy evaluation; logs resolution decisions
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,9 +21,9 @@ from .repository import CleanReleaseRepository
|
||||
|
||||
# #region resolve_trusted_policy_snapshots [TYPE Function]
|
||||
# @BRIEF Resolve immutable trusted policy and registry snapshots using active config IDs only.
|
||||
# @PRE ConfigManager provides active_policy_id and active_registry_id; repository contains referenced snapshots.
|
||||
# @POST Returns immutable policy and registry snapshots; runtime override attempts are rejected.
|
||||
# @SIDE_EFFECT None.
|
||||
# @PRE: ConfigManager provides active_policy_id and active_registry_id; repository contains referenced snapshots.
|
||||
# @POST: Returns immutable policy and registry snapshots; runtime override attempts are rejected.
|
||||
# @SIDE_EFFECT: None.
|
||||
def resolve_trusted_policy_snapshots(
|
||||
*,
|
||||
config_manager,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# #region PreparationService [C:3] [TYPE Module] [SEMANTICS clean-release, preparation, manifest, policy-evaluation]
|
||||
# @BRIEF Prepare release candidate by policy evaluation and deterministic manifest creation.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT Candidate preparation always persists manifest and candidate status deterministically.
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [PolicyEngine]
|
||||
# @RELATION DEPENDS_ON -> [ManifestBuilder]
|
||||
# @RELATION DEPENDS_ON -> [RepositoryRelations]
|
||||
# @INVARIANT: Candidate preparation always persists manifest and candidate status deterministically.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -89,8 +89,8 @@ def prepare_candidate(
|
||||
|
||||
# #region prepare_candidate_legacy [TYPE Function]
|
||||
# @BRIEF Legacy compatibility wrapper kept for migration period.
|
||||
# @PRE Same as prepare_candidate.
|
||||
# @POST Delegates to canonical prepare_candidate and preserves response shape.
|
||||
# @PRE: Same as prepare_candidate.
|
||||
# @POST: Delegates to canonical prepare_candidate and preserves response shape.
|
||||
def prepare_candidate_legacy(
|
||||
repository: CleanReleaseRepository,
|
||||
candidate_id: str,
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
# [DEF:PublicationService:Module]
|
||||
# @COMPLEXITY: 5
|
||||
# @SEMANTICS: clean-release, publication, revoke, gate, lifecycle
|
||||
# @PURPOSE: Enforce publication and revocation gates with append-only publication records.
|
||||
# #region PublicationService [C:5] [TYPE Module] [SEMANTICS clean-release, publication, revoke, gate, lifecycle]
|
||||
# @BRIEF Enforce publication and revocation gates with append-only publication records.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: [DEPENDS_ON] ->[RepositoryRelations]
|
||||
# @RELATION: [DEPENDS_ON] ->[ApprovalService]
|
||||
# @RELATION: [DEPENDS_ON] ->[CleanReleaseModels]
|
||||
# @RELATION: [DEPENDS_ON] ->[AuditService]
|
||||
# @RELATION DEPENDS_ON -> [RepositoryRelations]
|
||||
# @RELATION DEPENDS_ON -> [ApprovalService]
|
||||
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
|
||||
# @RELATION DEPENDS_ON -> [AuditService]
|
||||
# @INVARIANT: Publication records are append-only snapshots; revoke mutates only publication status for targeted record.
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,8 +23,8 @@ from .repository import CleanReleaseRepository
|
||||
|
||||
# #region _get_or_init_publications_store [TYPE Function]
|
||||
# @BRIEF Provide in-memory append-only publication storage.
|
||||
# @PRE repository is initialized.
|
||||
# @POST Returns publication list attached to repository.
|
||||
# @PRE: repository is initialized.
|
||||
# @POST: Returns publication list attached to repository.
|
||||
def _get_or_init_publications_store(
|
||||
repository: CleanReleaseRepository,
|
||||
) -> List[PublicationRecord]:
|
||||
@@ -42,8 +40,8 @@ def _get_or_init_publications_store(
|
||||
|
||||
# #region _latest_publication_for_candidate [TYPE Function]
|
||||
# @BRIEF Resolve latest publication record for candidate.
|
||||
# @PRE candidate_id is non-empty.
|
||||
# @POST Returns latest record or None.
|
||||
# @PRE: candidate_id is non-empty.
|
||||
# @POST: Returns latest record or None.
|
||||
def _latest_publication_for_candidate(
|
||||
repository: CleanReleaseRepository,
|
||||
candidate_id: str,
|
||||
@@ -67,8 +65,8 @@ def _latest_publication_for_candidate(
|
||||
|
||||
# #region _latest_approval_for_candidate [TYPE Function]
|
||||
# @BRIEF Resolve latest approval decision from repository decision store.
|
||||
# @PRE candidate_id is non-empty.
|
||||
# @POST Returns latest decision object or None.
|
||||
# @PRE: candidate_id is non-empty.
|
||||
# @POST: Returns latest decision object or None.
|
||||
def _latest_approval_for_candidate(
|
||||
repository: CleanReleaseRepository, candidate_id: str
|
||||
):
|
||||
@@ -88,8 +86,8 @@ def _latest_approval_for_candidate(
|
||||
|
||||
# #region publish_candidate [TYPE Function]
|
||||
# @BRIEF Create immutable publication record for approved candidate.
|
||||
# @PRE Candidate exists, report belongs to candidate, latest approval is APPROVED.
|
||||
# @POST New ACTIVE publication record is appended.
|
||||
# @PRE: Candidate exists, report belongs to candidate, latest approval is APPROVED.
|
||||
# @POST: New ACTIVE publication record is appended.
|
||||
def publish_candidate(
|
||||
*,
|
||||
repository: CleanReleaseRepository,
|
||||
@@ -168,8 +166,8 @@ def publish_candidate(
|
||||
|
||||
# #region revoke_publication [TYPE Function]
|
||||
# @BRIEF Revoke existing publication record without deleting history.
|
||||
# @PRE publication_id exists in repository publication store.
|
||||
# @POST Target publication status becomes REVOKED and updated record is returned.
|
||||
# @PRE: publication_id exists in repository publication store.
|
||||
# @POST: Target publication status becomes REVOKED and updated record is returned.
|
||||
def revoke_publication(
|
||||
*,
|
||||
repository: CleanReleaseRepository,
|
||||
@@ -212,4 +210,4 @@ def revoke_publication(
|
||||
|
||||
# #endregion revoke_publication
|
||||
|
||||
# [/DEF:backend.src.services.clean_release.publication_service:Module]
|
||||
# #endregion backend.src.services.clean_release.publication_service
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# #region ReportBuilder [C:5] [TYPE Module] [SEMANTICS clean-release, report, audit, counters, violations]
|
||||
# @BRIEF Build and persist compliance reports with consistent counter invariants.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT blocking_violations_count never exceeds violations_count.
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
|
||||
# @RELATION DEPENDS_ON -> [RepositoryRelations]
|
||||
# @INVARIANT: blocking_violations_count never exceeds violations_count.
|
||||
# @TEST_CONTRACT: ComplianceCheckRun,List[ComplianceViolation] -> ComplianceReport
|
||||
# @TEST_FIXTURE: blocked_with_two_violations -> file:backend/tests/fixtures/clean_release/fixtures_clean_release.json
|
||||
# @TEST_EDGE: empty_violations_for_blocked -> BLOCKED run with zero blocking violations raises ValueError
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# #region clean_release_repositories [C:3] [TYPE Module]
|
||||
# @BRIEF Export all clean release repositories.
|
||||
# @RELATION DEPENDS_ON -> [sqlalchemy]
|
||||
# @RELATION DEPENDS_ON -> sqlalchemy
|
||||
|
||||
from .candidate_repository import CandidateRepository
|
||||
from .artifact_repository import ArtifactRepository
|
||||
@@ -25,4 +25,4 @@ __all__ = [
|
||||
"CleanReleaseAuditLog"
|
||||
]
|
||||
|
||||
# #endregion clean_release_repositories
|
||||
# #endregion clean_release_repositories
|
||||
@@ -1,7 +1,7 @@
|
||||
# #region approval_repository [C:3] [TYPE Module]
|
||||
# @BRIEF Persist and query approval decisions.
|
||||
# @LAYER Infra
|
||||
# @RELATION DEPENDS_ON -> [sqlalchemy]
|
||||
# @LAYER: Infra
|
||||
# @RELATION DEPENDS_ON -> sqlalchemy
|
||||
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -50,4 +50,4 @@ class ApprovalRepository:
|
||||
with belief_scope("ApprovalRepository.list_by_candidate"):
|
||||
return self.db.query(ApprovalDecision).filter(ApprovalDecision.candidate_id == candidate_id).all()
|
||||
|
||||
# #endregion approval_repository
|
||||
# #endregion approval_repository
|
||||
@@ -1,7 +1,7 @@
|
||||
# #region artifact_repository [C:3] [TYPE Module]
|
||||
# @BRIEF Persist and query candidate artifacts.
|
||||
# @LAYER Infra
|
||||
# @RELATION DEPENDS_ON -> [sqlalchemy]
|
||||
# @LAYER: Infra
|
||||
# @RELATION DEPENDS_ON -> sqlalchemy
|
||||
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -51,4 +51,4 @@ class ArtifactRepository:
|
||||
with belief_scope("ArtifactRepository.list_by_candidate"):
|
||||
return self.db.query(CandidateArtifact).filter(CandidateArtifact.candidate_id == candidate_id).all()
|
||||
|
||||
# #endregion artifact_repository
|
||||
# #endregion artifact_repository
|
||||
@@ -1,7 +1,7 @@
|
||||
# #region audit_repository [C:3] [TYPE Module]
|
||||
# @BRIEF Persist and query audit logs for clean release operations.
|
||||
# @LAYER Infra
|
||||
# @RELATION DEPENDS_ON -> [sqlalchemy]
|
||||
# @LAYER: Infra
|
||||
# @RELATION DEPENDS_ON -> sqlalchemy
|
||||
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -43,4 +43,4 @@ class AuditRepository:
|
||||
with belief_scope("AuditRepository.list_by_candidate"):
|
||||
return self.db.query(CleanReleaseAuditLog).filter(CleanReleaseAuditLog.candidate_id == candidate_id).all()
|
||||
|
||||
# #endregion audit_repository
|
||||
# #endregion audit_repository
|
||||
@@ -1,7 +1,7 @@
|
||||
# #region candidate_repository [C:3] [TYPE Module]
|
||||
# @BRIEF Persist and query release candidates.
|
||||
# @LAYER Infra
|
||||
# @RELATION DEPENDS_ON -> [sqlalchemy]
|
||||
# @LAYER: Infra
|
||||
# @RELATION DEPENDS_ON -> sqlalchemy
|
||||
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -44,4 +44,4 @@ class CandidateRepository:
|
||||
with belief_scope("CandidateRepository.list_all"):
|
||||
return self.db.query(ReleaseCandidate).all()
|
||||
|
||||
# #endregion candidate_repository
|
||||
# #endregion candidate_repository
|
||||
@@ -1,7 +1,7 @@
|
||||
# #region compliance_repository [C:3] [TYPE Module]
|
||||
# @BRIEF Persist and query compliance runs, stage runs, and violations.
|
||||
# @LAYER Infra
|
||||
# @RELATION DEPENDS_ON -> [sqlalchemy]
|
||||
# @LAYER: Infra
|
||||
# @RELATION DEPENDS_ON -> sqlalchemy
|
||||
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -84,4 +84,4 @@ class ComplianceRepository:
|
||||
with belief_scope("ComplianceRepository.list_violations_by_run"):
|
||||
return self.db.query(ComplianceViolation).filter(ComplianceViolation.run_id == run_id).all()
|
||||
|
||||
# #endregion compliance_repository
|
||||
# #endregion compliance_repository
|
||||
@@ -1,9 +1,9 @@
|
||||
# #region ManifestRepositoryModule [C:3] [TYPE Module]
|
||||
# @BRIEF Persist and query distribution manifests.
|
||||
# @LAYER Infra
|
||||
# @RELATION DEPENDS_ON -> [DistributionManifest]
|
||||
# @RELATION DEPENDS_ON -> [sqlalchemy]
|
||||
# @RELATION DEPENDS_ON -> [belief_scope]
|
||||
# @LAYER: Infra
|
||||
# @RELATION DEPENDS_ON -> DistributionManifest
|
||||
# @RELATION DEPENDS_ON -> sqlalchemy
|
||||
# @RELATION DEPENDS_ON -> belief_scope
|
||||
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -13,50 +13,54 @@ from src.core.logger import belief_scope
|
||||
|
||||
# #region ManifestRepository [C:3] [TYPE Class]
|
||||
# @BRIEF Encapsulates database CRUD operations for DistributionManifest entities.
|
||||
# @RELATION DEPENDS_ON -> [DistributionManifest]
|
||||
# @RELATION DEPENDS_ON -> [sqlalchemy.Session]
|
||||
# @RELATION DEPENDS_ON -> DistributionManifest
|
||||
# @RELATION DEPENDS_ON -> sqlalchemy.Session
|
||||
class ManifestRepository:
|
||||
"""Repository for distribution manifest persistence."""
|
||||
|
||||
# #region ManifestRepository.__init__ [C:1] [TYPE Function]
|
||||
# @BRIEF Initialize repository with an active SQLAlchemy session.
|
||||
# @PRE db is a valid SQLAlchemy Session instance.
|
||||
# @POST Repository is ready for database operations.
|
||||
# [DEF:ManifestRepository.__init__:Function]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: Initialize repository with an active SQLAlchemy session.
|
||||
# @PRE: db is a valid SQLAlchemy Session instance.
|
||||
# @POST: Repository is ready for database operations.
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
# #endregion ManifestRepository.__init__
|
||||
# [/DEF:ManifestRepository.__init__:Function]
|
||||
|
||||
# #region ManifestRepository.save [C:3] [TYPE Function]
|
||||
# @BRIEF Persist a DistributionManifest to the database.
|
||||
# @PRE manifest is a valid DistributionManifest instance with required fields populated.
|
||||
# @POST Manifest is committed to database and refreshed with generated ID.
|
||||
# @SIDE_EFFECT Database commit via session.commit().
|
||||
# @RELATION DEPENDS_ON -> [DistributionManifest]
|
||||
# [DEF:ManifestRepository.save:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Persist a DistributionManifest to the database.
|
||||
# @PRE: manifest is a valid DistributionManifest instance with required fields populated.
|
||||
# @POST: Manifest is committed to database and refreshed with generated ID.
|
||||
# @SIDE_EFFECT: Database commit via session.commit().
|
||||
# @RELATION: DEPENDS_ON -> DistributionManifest
|
||||
def save(self, manifest: DistributionManifest) -> DistributionManifest:
|
||||
with belief_scope("ManifestRepository.save"):
|
||||
self.db.add(manifest)
|
||||
self.db.commit()
|
||||
self.db.refresh(manifest)
|
||||
return manifest
|
||||
# #endregion ManifestRepository.save
|
||||
# [/DEF:ManifestRepository.save:Function]
|
||||
|
||||
# #region ManifestRepository.get_by_id [C:2] [TYPE Function]
|
||||
# @BRIEF Retrieve a single DistributionManifest by its primary key.
|
||||
# @PRE manifest_id is a valid string identifier.
|
||||
# @POST Returns DistributionManifest if found, None otherwise.
|
||||
# @RELATION DEPENDS_ON -> [DistributionManifest]
|
||||
# [DEF:ManifestRepository.get_by_id:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Retrieve a single DistributionManifest by its primary key.
|
||||
# @PRE: manifest_id is a valid string identifier.
|
||||
# @POST: Returns DistributionManifest if found, None otherwise.
|
||||
# @RELATION: DEPENDS_ON -> DistributionManifest
|
||||
def get_by_id(self, manifest_id: str) -> Optional[DistributionManifest]:
|
||||
with belief_scope("ManifestRepository.get_by_id"):
|
||||
return self.db.query(DistributionManifest).filter(
|
||||
DistributionManifest.id == manifest_id
|
||||
).first()
|
||||
# #endregion ManifestRepository.get_by_id
|
||||
# [/DEF:ManifestRepository.get_by_id:Function]
|
||||
|
||||
# #region ManifestRepository.get_latest_for_candidate [C:3] [TYPE Function]
|
||||
# @BRIEF Retrieve the most recent manifest version for a given candidate.
|
||||
# @PRE candidate_id is a valid string identifier.
|
||||
# @POST Returns the highest manifest_version manifest for the candidate, or None.
|
||||
# @RELATION DEPENDS_ON -> [DistributionManifest]
|
||||
# [DEF:ManifestRepository.get_latest_for_candidate:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Retrieve the most recent manifest version for a given candidate.
|
||||
# @PRE: candidate_id is a valid string identifier.
|
||||
# @POST: Returns the highest manifest_version manifest for the candidate, or None.
|
||||
# @RELATION: DEPENDS_ON -> DistributionManifest
|
||||
def get_latest_for_candidate(self, candidate_id: str) -> Optional[DistributionManifest]:
|
||||
with belief_scope("ManifestRepository.get_latest_for_candidate"):
|
||||
return (
|
||||
@@ -65,13 +69,14 @@ class ManifestRepository:
|
||||
.order_by(DistributionManifest.manifest_version.desc())
|
||||
.first()
|
||||
)
|
||||
# #endregion ManifestRepository.get_latest_for_candidate
|
||||
# [/DEF:ManifestRepository.get_latest_for_candidate:Function]
|
||||
|
||||
# #region ManifestRepository.list_by_candidate [C:2] [TYPE Function]
|
||||
# @BRIEF List all manifests for a specific candidate, ordered by version.
|
||||
# @PRE candidate_id is a valid string identifier.
|
||||
# @POST Returns a list of DistributionManifest instances (may be empty).
|
||||
# @RELATION DEPENDS_ON -> [DistributionManifest]
|
||||
# [DEF:ManifestRepository.list_by_candidate:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: List all manifests for a specific candidate, ordered by version.
|
||||
# @PRE: candidate_id is a valid string identifier.
|
||||
# @POST: Returns a list of DistributionManifest instances (may be empty).
|
||||
# @RELATION: DEPENDS_ON -> DistributionManifest
|
||||
def list_by_candidate(self, candidate_id: str) -> List[DistributionManifest]:
|
||||
with belief_scope("ManifestRepository.list_by_candidate"):
|
||||
return (
|
||||
@@ -79,6 +84,8 @@ class ManifestRepository:
|
||||
.filter(DistributionManifest.candidate_id == candidate_id)
|
||||
.all()
|
||||
)
|
||||
# #endregion ManifestRepository.list_by_candidate
|
||||
# [/DEF:ManifestRepository.list_by_candidate:Function]
|
||||
|
||||
# #endregion ManifestRepository
|
||||
|
||||
# #endregion ManifestRepositoryModule
|
||||
@@ -1,7 +1,7 @@
|
||||
# #region policy_repository [C:3] [TYPE Module]
|
||||
# @BRIEF Persist and query policy and registry snapshots.
|
||||
# @LAYER Infra
|
||||
# @RELATION DEPENDS_ON -> [sqlalchemy]
|
||||
# @LAYER: Infra
|
||||
# @RELATION DEPENDS_ON -> sqlalchemy
|
||||
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -49,4 +49,4 @@ class PolicyRepository:
|
||||
with belief_scope("PolicyRepository.get_registry_snapshot"):
|
||||
return self.db.query(SourceRegistrySnapshot).filter(SourceRegistrySnapshot.id == snapshot_id).first()
|
||||
|
||||
# #endregion policy_repository
|
||||
# #endregion policy_repository
|
||||
@@ -1,7 +1,7 @@
|
||||
# #region publication_repository [C:3] [TYPE Module]
|
||||
# @BRIEF Persist and query publication records.
|
||||
# @LAYER Infra
|
||||
# @RELATION DEPENDS_ON -> [sqlalchemy]
|
||||
# @LAYER: Infra
|
||||
# @RELATION DEPENDS_ON -> sqlalchemy
|
||||
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -50,4 +50,4 @@ class PublicationRepository:
|
||||
with belief_scope("PublicationRepository.list_by_candidate"):
|
||||
return self.db.query(PublicationRecord).filter(PublicationRecord.candidate_id == candidate_id).all()
|
||||
|
||||
# #endregion publication_repository
|
||||
# #endregion publication_repository
|
||||
@@ -1,7 +1,7 @@
|
||||
# #region report_repository [C:3] [TYPE Module]
|
||||
# @BRIEF Persist and query compliance reports.
|
||||
# @LAYER Infra
|
||||
# @RELATION DEPENDS_ON -> [sqlalchemy]
|
||||
# @LAYER: Infra
|
||||
# @RELATION DEPENDS_ON -> sqlalchemy
|
||||
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -47,4 +47,4 @@ class ReportRepository:
|
||||
with belief_scope("ReportRepository.list_by_candidate"):
|
||||
return self.db.query(ComplianceReport).filter(ComplianceReport.candidate_id == candidate_id).all()
|
||||
|
||||
# #endregion report_repository
|
||||
# #endregion report_repository
|
||||
@@ -1,8 +1,8 @@
|
||||
# #region RepositoryRelations [C:3] [TYPE Module] [SEMANTICS clean-release, repository, persistence, in-memory]
|
||||
# @BRIEF Provide repository adapter for clean release entities with deterministic access methods.
|
||||
# @LAYER Infra
|
||||
# @INVARIANT Repository operations are side-effect free outside explicit save/update calls.
|
||||
# @LAYER: Infra
|
||||
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
|
||||
# @INVARIANT: Repository operations are side-effect free outside explicit save/update calls.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# #region SourceIsolation [C:3] [TYPE Module] [SEMANTICS clean-release, source-isolation, internal-only, validation]
|
||||
# @BRIEF Validate that all resource endpoints belong to the approved internal source registry.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT Any endpoint outside enabled registry entries is treated as external-source violation.
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
|
||||
# @INVARIANT: Any endpoint outside enabled registry entries is treated as external-source violation.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# #region ComplianceStages [C:3] [TYPE Module] [SEMANTICS clean-release, compliance, stages, state-machine]
|
||||
# @BRIEF Define compliance stage order and helper functions for deterministic run-state evaluation.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT Stage order remains deterministic for all compliance runs.
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
|
||||
# @RELATION DEPENDS_ON -> [ComplianceStageBase]
|
||||
# @INVARIANT: Stage order remains deterministic for all compliance runs.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -32,8 +32,8 @@ MANDATORY_STAGE_ORDER: List[ComplianceStageName] = [
|
||||
|
||||
# #region build_default_stages [TYPE Function]
|
||||
# @BRIEF Build default deterministic stage pipeline implementation order.
|
||||
# @PRE None.
|
||||
# @POST Returns stage instances in mandatory execution order.
|
||||
# @PRE: None.
|
||||
# @POST: Returns stage instances in mandatory execution order.
|
||||
def build_default_stages() -> List[ComplianceStage]:
|
||||
return [
|
||||
DataPurityStage(),
|
||||
@@ -48,8 +48,8 @@ def build_default_stages() -> List[ComplianceStage]:
|
||||
|
||||
# #region stage_result_map [TYPE Function]
|
||||
# @BRIEF Convert stage result list to dictionary by stage name.
|
||||
# @PRE stage_results may be empty or contain unique stage names.
|
||||
# @POST Returns stage->status dictionary for downstream evaluation.
|
||||
# @PRE: stage_results may be empty or contain unique stage names.
|
||||
# @POST: Returns stage->status dictionary for downstream evaluation.
|
||||
def stage_result_map(
|
||||
stage_results: Iterable[ComplianceStageRun | CheckStageResult],
|
||||
) -> Dict[ComplianceStageName, CheckStageStatus]:
|
||||
@@ -87,8 +87,8 @@ def stage_result_map(
|
||||
|
||||
# #region missing_mandatory_stages [TYPE Function]
|
||||
# @BRIEF Identify mandatory stages that are absent from run results.
|
||||
# @PRE stage_status_map contains zero or more known stage statuses.
|
||||
# @POST Returns ordered list of missing mandatory stages.
|
||||
# @PRE: stage_status_map contains zero or more known stage statuses.
|
||||
# @POST: Returns ordered list of missing mandatory stages.
|
||||
def missing_mandatory_stages(
|
||||
stage_status_map: Dict[ComplianceStageName, CheckStageStatus],
|
||||
) -> List[ComplianceStageName]:
|
||||
@@ -100,8 +100,8 @@ def missing_mandatory_stages(
|
||||
|
||||
# #region derive_final_status [TYPE Function]
|
||||
# @BRIEF Derive final run status from stage results with deterministic blocking behavior.
|
||||
# @PRE Stage statuses correspond to compliance checks.
|
||||
# @POST Returns one of PASSED/BLOCKED/ERROR according to mandatory stage outcomes.
|
||||
# @PRE: Stage statuses correspond to compliance checks.
|
||||
# @POST: Returns one of PASSED/BLOCKED/ERROR according to mandatory stage outcomes.
|
||||
def derive_final_status(
|
||||
stage_results: Iterable[ComplianceStageRun | CheckStageResult],
|
||||
) -> CheckFinalStatus:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# #region ComplianceStageBase [C:3] [TYPE Module] [SEMANTICS clean-release, compliance, stages, contracts, base]
|
||||
# @BRIEF Define shared contracts and helpers for pluggable clean-release compliance stages.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT Stage execution is deterministic for equal input context.
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
|
||||
# @RELATION DEPENDS_ON -> [LoggerModule]
|
||||
# @INVARIANT: Stage execution is deterministic for equal input context.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -65,8 +65,8 @@ class ComplianceStage(Protocol):
|
||||
|
||||
# #region build_stage_run_record [TYPE Function]
|
||||
# @BRIEF Build persisted stage run record from stage result.
|
||||
# @PRE run_id and stage_name are non-empty.
|
||||
# @POST Returns ComplianceStageRun with deterministic identifiers and timestamps.
|
||||
# @PRE: run_id and stage_name are non-empty.
|
||||
# @POST: Returns ComplianceStageRun with deterministic identifiers and timestamps.
|
||||
def build_stage_run_record(
|
||||
*,
|
||||
run_id: str,
|
||||
@@ -96,8 +96,8 @@ def build_stage_run_record(
|
||||
|
||||
# #region build_violation [TYPE Function]
|
||||
# @BRIEF Construct a compliance violation with normalized defaults.
|
||||
# @PRE run_id, stage_name, code and message are non-empty.
|
||||
# @POST Returns immutable-style violation payload ready for persistence.
|
||||
# @PRE: run_id, stage_name, code and message are non-empty.
|
||||
# @POST: Returns immutable-style violation payload ready for persistence.
|
||||
def build_violation(
|
||||
*,
|
||||
run_id: str,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# #region data_purity [C:3] [TYPE Module] [SEMANTICS clean-release, compliance-stage, data-purity]
|
||||
# @BRIEF Evaluate manifest purity counters and emit blocking violations for prohibited artifacts.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT prohibited_detected_count > 0 always yields BLOCKED stage decision.
|
||||
# @LAYER: Domain
|
||||
# @RELATION IMPLEMENTS -> [ComplianceStage]
|
||||
# @RELATION DEPENDS_ON -> [ComplianceStageBase]
|
||||
# @INVARIANT: prohibited_detected_count > 0 always yields BLOCKED stage decision.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,8 +14,8 @@ from .base import ComplianceStageContext, StageExecutionResult, build_violation
|
||||
|
||||
# #region DataPurityStage [TYPE Class]
|
||||
# @BRIEF Validate manifest summary for prohibited artifacts.
|
||||
# @PRE context.manifest.content_json contains summary block or defaults to safe counters.
|
||||
# @POST Returns PASSED when no prohibited artifacts are detected, otherwise BLOCKED with violations.
|
||||
# @PRE: context.manifest.content_json contains summary block or defaults to safe counters.
|
||||
# @POST: Returns PASSED when no prohibited artifacts are detected, otherwise BLOCKED with violations.
|
||||
class DataPurityStage:
|
||||
stage_name = ComplianceStageName.DATA_PURITY
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# #region internal_sources_only [C:3] [TYPE Module] [SEMANTICS clean-release, compliance-stage, source-isolation, registry]
|
||||
# @BRIEF Verify manifest-declared sources belong to trusted internal registry allowlist.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT Any source host outside allowed_hosts yields BLOCKED decision with at least one violation.
|
||||
# @LAYER: Domain
|
||||
# @RELATION IMPLEMENTS -> [ComplianceStage]
|
||||
# @RELATION DEPENDS_ON -> [ComplianceStageBase]
|
||||
# @INVARIANT: Any source host outside allowed_hosts yields BLOCKED decision with at least one violation.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,8 +14,8 @@ from .base import ComplianceStageContext, StageExecutionResult, build_violation
|
||||
|
||||
# #region InternalSourcesOnlyStage [TYPE Class]
|
||||
# @BRIEF Enforce internal-source-only policy from trusted registry snapshot.
|
||||
# @PRE context.registry.allowed_hosts is available.
|
||||
# @POST Returns PASSED when all hosts are allowed; otherwise BLOCKED and violations captured.
|
||||
# @PRE: context.registry.allowed_hosts is available.
|
||||
# @POST: Returns PASSED when all hosts are allowed; otherwise BLOCKED and violations captured.
|
||||
class InternalSourcesOnlyStage:
|
||||
stage_name = ComplianceStageName.INTERNAL_SOURCES_ONLY
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# #region manifest_consistency [C:3] [TYPE Module] [SEMANTICS clean-release, compliance-stage, manifest, consistency, digest]
|
||||
# @BRIEF Ensure run is bound to the exact manifest snapshot and digest used at run creation time.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT Digest mismatch between run and manifest yields ERROR with blocking violation evidence.
|
||||
# @LAYER: Domain
|
||||
# @RELATION IMPLEMENTS -> [ComplianceStage]
|
||||
# @RELATION DEPENDS_ON -> [ComplianceStageBase]
|
||||
# @INVARIANT: Digest mismatch between run and manifest yields ERROR with blocking violation evidence.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,8 +14,8 @@ from .base import ComplianceStageContext, StageExecutionResult, build_violation
|
||||
|
||||
# #region ManifestConsistencyStage [TYPE Class]
|
||||
# @BRIEF Validate run/manifest linkage consistency.
|
||||
# @PRE context.run and context.manifest are loaded from repository for same run.
|
||||
# @POST Returns PASSED when digests match, otherwise ERROR with one violation.
|
||||
# @PRE: context.run and context.manifest are loaded from repository for same run.
|
||||
# @POST: Returns PASSED when digests match, otherwise ERROR with one violation.
|
||||
class ManifestConsistencyStage:
|
||||
stage_name = ComplianceStageName.MANIFEST_CONSISTENCY
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# #region no_external_endpoints [C:3] [TYPE Module] [SEMANTICS clean-release, compliance-stage, endpoints, network]
|
||||
# @BRIEF Block manifest payloads that expose external endpoints outside trusted schemes and hosts.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT Endpoint outside allowed scheme/host always yields BLOCKED stage decision.
|
||||
# @LAYER: Domain
|
||||
# @RELATION IMPLEMENTS -> [ComplianceStage]
|
||||
# @RELATION DEPENDS_ON -> [ComplianceStageBase]
|
||||
# @INVARIANT: Endpoint outside allowed scheme/host always yields BLOCKED stage decision.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -16,8 +16,8 @@ from .base import ComplianceStageContext, StageExecutionResult, build_violation
|
||||
|
||||
# #region NoExternalEndpointsStage [TYPE Class]
|
||||
# @BRIEF Validate endpoint references from manifest against trusted registry.
|
||||
# @PRE context.registry includes allowed hosts and schemes.
|
||||
# @POST Returns PASSED when all endpoints are trusted, otherwise BLOCKED with endpoint violations.
|
||||
# @PRE: context.registry includes allowed hosts and schemes.
|
||||
# @POST: Returns PASSED when all endpoints are trusted, otherwise BLOCKED with endpoint violations.
|
||||
class NoExternalEndpointsStage:
|
||||
stage_name = ComplianceStageName.NO_EXTERNAL_ENDPOINTS
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# #region dataset_review [TYPE Module] [SEMANTICS dataset, review, orchestration]
|
||||
#
|
||||
# @BRIEF Provides services for dataset-centered orchestration flow.
|
||||
# @LAYER Services
|
||||
# @RELATION EXPORTS -> [DatasetReviewOrchestrator:Class]
|
||||
# @LAYER: Services
|
||||
#
|
||||
#
|
||||
# #endregion dataset_review
|
||||
# #endregion dataset_review
|
||||
@@ -1,19 +1,19 @@
|
||||
# #region ClarificationEngine [C:4] [TYPE Module] [SEMANTICS dataset_review, clarification, question_payload, answer_persistence, readiness, findings]
|
||||
# @BRIEF Manage one-question-at-a-time clarification state, deterministic answer persistence, and readiness/finding updates.
|
||||
# @LAYER Domain
|
||||
# @PRE Target session contains a persisted clarification aggregate in the current ownership scope.
|
||||
# @POST Active clarification payload exposes one highest-priority unresolved question, and each recorded answer is persisted before pointer/readiness mutation.
|
||||
# @SIDE_EFFECT Persists clarification answers, question/session states, and related readiness/finding changes.
|
||||
# @DATA_CONTRACT Input[DatasetReviewSession|ClarificationAnswerCommand] -> Output[ClarificationStateResult]
|
||||
# @INVARIANT Only one active clarification question may exist at a time; skipped and expert-review items remain unresolved and visible.
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [DatasetReviewSessionRepository]
|
||||
# @RELATION DEPENDS_ON -> [ClarificationSession]
|
||||
# @RELATION DEPENDS_ON -> [ClarificationQuestion]
|
||||
# @RELATION DEPENDS_ON -> [ClarificationAnswer]
|
||||
# @RELATION DEPENDS_ON -> [ValidationFinding]
|
||||
# @RELATION DISPATCHES -> [ClarificationHelpers:Module]
|
||||
# @RATIONALE Original 635-line file exceeded INV_7 (400-line module limit). Extracted pure helpers into _helpers sub-module.
|
||||
# @REJECTED Keeping all clarification logic in one file because it exceeded the fractal limit.
|
||||
# @PRE: Target session contains a persisted clarification aggregate in the current ownership scope.
|
||||
# @POST: Active clarification payload exposes one highest-priority unresolved question, and each recorded answer is persisted before pointer/readiness mutation.
|
||||
# @SIDE_EFFECT: Persists clarification answers, question/session states, and related readiness/finding changes.
|
||||
# @DATA_CONTRACT: Input[DatasetReviewSession|ClarificationAnswerCommand] -> Output[ClarificationStateResult]
|
||||
# @INVARIANT: Only one active clarification question may exist at a time; skipped and expert-review items remain unresolved and visible.
|
||||
# @RATIONALE: Original 635-line file exceeded INV_7 (400-line module limit). Extracted pure helpers into _helpers sub-module.
|
||||
# @REJECTED: Keeping all clarification logic in one file because it exceeded the fractal limit.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,8 +21,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from src.core.cot_logger import MarkerLogger
|
||||
from src.core.logger import belief_scope
|
||||
from src.core.logger import belief_scope, logger
|
||||
from src.models.auth import User
|
||||
from src.models.dataset_review import (
|
||||
AnswerKind,
|
||||
@@ -51,8 +50,6 @@ from src.services.dataset_review.clarification_pkg._helpers import (
|
||||
derive_recommended_action,
|
||||
)
|
||||
|
||||
log = MarkerLogger("ClarificationEngine")
|
||||
|
||||
|
||||
# #region ClarificationQuestionPayload [C:2] [TYPE Class]
|
||||
# @BRIEF Typed active-question payload returned to the API layer.
|
||||
@@ -101,31 +98,33 @@ class ClarificationAnswerCommand:
|
||||
|
||||
# #region ClarificationEngine [C:4] [TYPE Class]
|
||||
# @BRIEF Provide deterministic one-question-at-a-time clarification selection and answer persistence.
|
||||
# @PRE Repository is bound to the current request transaction scope.
|
||||
# @POST Returned clarification state is persistence-backed and aligned with session readiness/recommended action.
|
||||
# @SIDE_EFFECT Mutates clarification answers, session flags, and related clarification findings.
|
||||
# @RELATION DEPENDS_ON -> [DatasetReviewSessionRepository]
|
||||
# @RELATION CALLS -> [ClarificationHelpers:Module]
|
||||
# @PRE: Repository is bound to the current request transaction scope.
|
||||
# @POST: Returned clarification state is persistence-backed and aligned with session readiness/recommended action.
|
||||
# @SIDE_EFFECT: Mutates clarification answers, session flags, and related clarification findings.
|
||||
class ClarificationEngine:
|
||||
# #region ClarificationEngine_init [C:2] [TYPE Function]
|
||||
# @BRIEF Bind repository dependency for clarification persistence operations.
|
||||
# [DEF:ClarificationEngine_init:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Bind repository dependency for clarification persistence operations.
|
||||
def __init__(self, repository: DatasetReviewSessionRepository) -> None:
|
||||
self.repository = repository
|
||||
|
||||
# #endregion ClarificationEngine_init
|
||||
# [/DEF:ClarificationEngine_init:Function]
|
||||
|
||||
# #region build_question_payload [C:4] [TYPE Function]
|
||||
# @BRIEF Return the one active highest-priority clarification question payload.
|
||||
# @PRE Session contains unresolved clarification state or a resumable clarification session.
|
||||
# @POST Returns exactly one active/open question payload or None when no unresolved question remains.
|
||||
# @SIDE_EFFECT Normalizes the active-question pointer and clarification status in persistence.
|
||||
# [DEF:build_question_payload:Function]
|
||||
# @COMPLEXITY: 4
|
||||
# @PURPOSE: Return the one active highest-priority clarification question payload.
|
||||
# @PRE: Session contains unresolved clarification state or a resumable clarification session.
|
||||
# @POST: Returns exactly one active/open question payload or None when no unresolved question remains.
|
||||
# @SIDE_EFFECT: Normalizes the active-question pointer and clarification status in persistence.
|
||||
def build_question_payload(
|
||||
self, session: DatasetReviewSession,
|
||||
) -> Optional[ClarificationQuestionPayload]:
|
||||
with belief_scope("ClarificationEngine.build_question_payload"):
|
||||
clarification_session = self._get_latest_clarification_session(session)
|
||||
if clarification_session is None:
|
||||
log.reason("No clarification session found", payload={"session_id": session.session_id})
|
||||
logger.reason("No clarification session found", extra={"session_id": session.session_id})
|
||||
return None
|
||||
|
||||
active_questions = [
|
||||
@@ -141,7 +140,7 @@ class ClarificationEngine:
|
||||
if session.current_phase == SessionPhase.CLARIFICATION:
|
||||
session.current_phase = SessionPhase.REVIEW
|
||||
self.repository.db.commit()
|
||||
log.reflect("No unresolved clarification question remains", payload={"session_id": session.session_id})
|
||||
logger.reflect("No unresolved clarification question remains", extra={"session_id": session.session_id})
|
||||
return None
|
||||
|
||||
selected_question = active_questions[0]
|
||||
@@ -151,7 +150,7 @@ class ClarificationEngine:
|
||||
session.recommended_action = RecommendedAction.ANSWER_NEXT_QUESTION
|
||||
session.current_phase = SessionPhase.CLARIFICATION
|
||||
|
||||
log.reason("Selected active clarification question", payload={"session_id": session.session_id, "question_id": selected_question.question_id, "priority": selected_question.priority})
|
||||
logger.reason("Selected active clarification question", extra={"session_id": session.session_id, "question_id": selected_question.question_id, "priority": selected_question.priority})
|
||||
self.repository.db.commit()
|
||||
|
||||
payload = ClarificationQuestionPayload(
|
||||
@@ -168,40 +167,41 @@ class ClarificationEngine:
|
||||
for o in sorted(selected_question.options, key=lambda item: (item.display_order, item.label, item.option_id))
|
||||
],
|
||||
)
|
||||
log.reflect("Clarification payload built", payload={"session_id": session.session_id, "question_id": payload.question_id, "option_count": len(payload.options)})
|
||||
logger.reflect("Clarification payload built", extra={"session_id": session.session_id, "question_id": payload.question_id, "option_count": len(payload.options)})
|
||||
return payload
|
||||
|
||||
# #endregion build_question_payload
|
||||
# [/DEF:build_question_payload:Function]
|
||||
|
||||
# #region record_answer [C:4] [TYPE Function]
|
||||
# @BRIEF Persist one clarification answer before any pointer/readiness mutation.
|
||||
# @PRE Target question belongs to the session's active clarification session and is still open.
|
||||
# @POST Answer row is persisted before current-question pointer advances.
|
||||
# @SIDE_EFFECT Inserts answer row, mutates question/session states, updates clarification findings, and commits.
|
||||
# [DEF:record_answer:Function]
|
||||
# @COMPLEXITY: 4
|
||||
# @PURPOSE: Persist one clarification answer before any pointer/readiness mutation.
|
||||
# @PRE: Target question belongs to the session's active clarification session and is still open.
|
||||
# @POST: Answer row is persisted before current-question pointer advances.
|
||||
# @SIDE_EFFECT: Inserts answer row, mutates question/session states, updates clarification findings, and commits.
|
||||
def record_answer(self, command: ClarificationAnswerCommand) -> ClarificationStateResult:
|
||||
with belief_scope("ClarificationEngine.record_answer"):
|
||||
session = command.session
|
||||
clarification_session = self._get_latest_clarification_session(session)
|
||||
if clarification_session is None:
|
||||
log.explore("Cannot record clarification answer because no clarification session exists", payload={"session_id": session.session_id}, error="No clarification session found")
|
||||
logger.explore("Cannot record clarification answer because no clarification session exists", extra={"session_id": session.session_id})
|
||||
raise ValueError("Clarification session not found")
|
||||
|
||||
question = self._find_question(clarification_session, command.question_id)
|
||||
if question is None:
|
||||
log.explore("Cannot record clarification answer for foreign or missing question", payload={"session_id": session.session_id, "question_id": command.question_id}, error="Question not found in clarification session")
|
||||
logger.explore("Cannot record clarification answer for foreign or missing question", extra={"session_id": session.session_id, "question_id": command.question_id})
|
||||
raise ValueError("Clarification question not found")
|
||||
|
||||
if question.answer is not None:
|
||||
log.explore("Rejected duplicate clarification answer submission", payload={"session_id": session.session_id, "question_id": command.question_id}, error="Question already answered")
|
||||
logger.explore("Rejected duplicate clarification answer submission", extra={"session_id": session.session_id, "question_id": command.question_id})
|
||||
raise ValueError("Clarification question already answered")
|
||||
|
||||
if clarification_session.current_question_id and clarification_session.current_question_id != question.question_id:
|
||||
log.explore("Rejected answer for non-active clarification question", payload={"session_id": session.session_id, "question_id": question.question_id, "current_question_id": clarification_session.current_question_id}, error="Only active question can be answered")
|
||||
logger.explore("Rejected answer for non-active clarification question", extra={"session_id": session.session_id, "question_id": question.question_id, "current_question_id": clarification_session.current_question_id})
|
||||
raise ValueError("Only the active clarification question can be answered")
|
||||
|
||||
normalized_answer_value = normalize_answer_value(command.answer_kind, command.answer_value, question)
|
||||
|
||||
log.reason("Persisting clarification answer before state advancement", payload={"session_id": session.session_id, "question_id": question.question_id, "answer_kind": command.answer_kind.value})
|
||||
logger.reason("Persisting clarification answer before state advancement", extra={"session_id": session.session_id, "question_id": question.question_id, "answer_kind": command.answer_kind.value})
|
||||
persisted_answer = ClarificationAnswer(
|
||||
question_id=question.question_id,
|
||||
answer_kind=command.answer_kind,
|
||||
@@ -248,7 +248,7 @@ class ClarificationEngine:
|
||||
self.repository.db.commit()
|
||||
self.repository.db.refresh(session)
|
||||
|
||||
log.reflect("Clarification answer recorded and session advanced", payload={"session_id": session.session_id, "question_id": question.question_id, "next_question_id": clarification_session.current_question_id, "readiness_state": session.readiness_state.value, "remaining_count": clarification_session.remaining_count})
|
||||
logger.reflect("Clarification answer recorded and session advanced", extra={"session_id": session.session_id, "question_id": question.question_id, "next_question_id": clarification_session.current_question_id, "readiness_state": session.readiness_state.value, "remaining_count": clarification_session.remaining_count})
|
||||
|
||||
return ClarificationStateResult(
|
||||
clarification_session=clarification_session,
|
||||
@@ -257,36 +257,41 @@ class ClarificationEngine:
|
||||
changed_findings=[changed_finding] if changed_finding else [],
|
||||
)
|
||||
|
||||
# #endregion record_answer
|
||||
# [/DEF:record_answer:Function]
|
||||
|
||||
# #region summarize_progress [C:1] [TYPE Function]
|
||||
# @BRIEF Produce a compact progress summary for pause/resume and completion UX.
|
||||
# [DEF:summarize_progress:Function]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: Produce a compact progress summary for pause/resume and completion UX.
|
||||
def summarize_progress(self, clarification_session: ClarificationSession) -> str:
|
||||
resolved = count_resolved_questions(clarification_session)
|
||||
remaining = count_remaining_questions(clarification_session)
|
||||
return f"{resolved} resolved, {remaining} unresolved"
|
||||
|
||||
# #endregion summarize_progress
|
||||
# [/DEF:summarize_progress:Function]
|
||||
|
||||
# #region _get_latest_clarification_session [C:2] [TYPE Function]
|
||||
# @BRIEF Select the latest clarification session for the current dataset review aggregate.
|
||||
# [DEF:_get_latest_clarification_session:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Select the latest clarification session for the current dataset review aggregate.
|
||||
def _get_latest_clarification_session(self, session: DatasetReviewSession) -> Optional[ClarificationSession]:
|
||||
if not session.clarification_sessions:
|
||||
return None
|
||||
ordered = sorted(session.clarification_sessions, key=lambda item: (item.started_at, item.clarification_session_id), reverse=True)
|
||||
return ordered[0]
|
||||
|
||||
# #endregion _get_latest_clarification_session
|
||||
# [/DEF:_get_latest_clarification_session:Function]
|
||||
|
||||
# #region _find_question [C:1] [TYPE Function]
|
||||
# @BRIEF Resolve a clarification question from the active clarification aggregate.
|
||||
# [DEF:_find_question:Function]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: Resolve a clarification question from the active clarification aggregate.
|
||||
def _find_question(self, clarification_session: ClarificationSession, question_id: str) -> Optional[ClarificationQuestion]:
|
||||
for q in clarification_session.questions:
|
||||
if q.question_id == question_id:
|
||||
return q
|
||||
return None
|
||||
|
||||
# #endregion _find_question
|
||||
# [/DEF:_find_question:Function]
|
||||
|
||||
|
||||
# #endregion ClarificationEngine
|
||||
|
||||
# #endregion ClarificationEngine
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# #region ClarificationHelpers [C:3] [TYPE Module]
|
||||
# @BRIEF Pure helper functions for clarification engine — question selection, counting, normalization, finding upsert, and readiness derivation.
|
||||
# @LAYER Domain
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [DatasetReviewModels]
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# #region SessionEventLoggerModule [C:4] [TYPE Module] [SEMANTICS dataset_review, audit, session_events, persistence, observability]
|
||||
# @BRIEF Persist explicit session mutation events for dataset-review audit trails without weakening ownership or approval invariants.
|
||||
# @LAYER Domain
|
||||
# @PRE Caller provides an owned session scope and an authenticated actor identifier for each persisted mutation event.
|
||||
# @POST Every logged event is committed as an explicit, queryable audit record with deterministic event metadata.
|
||||
# @SIDE_EFFECT Inserts persisted session event rows and emits runtime belief-state logs for audit-sensitive mutations.
|
||||
# @DATA_CONTRACT Input[SessionEventPayload] -> Output[SessionEvent]
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [SessionEvent]
|
||||
# @RELATION DEPENDS_ON -> [DatasetReviewSession]
|
||||
# @PRE: Caller provides an owned session scope and an authenticated actor identifier for each persisted mutation event.
|
||||
# @POST: Every logged event is committed as an explicit, queryable audit record with deterministic event metadata.
|
||||
# @SIDE_EFFECT: Inserts persisted session event rows and emits runtime belief-state logs for audit-sensitive mutations.
|
||||
# @DATA_CONTRACT: Input[SessionEventPayload] -> Output[SessionEvent]
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -16,13 +16,10 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.core.cot_logger import MarkerLogger
|
||||
from src.core.logger import belief_scope
|
||||
from src.core.logger import belief_scope, logger
|
||||
from src.models.dataset_review import DatasetReviewSession, SessionEvent
|
||||
# #endregion SessionEventLoggerImports
|
||||
|
||||
log = MarkerLogger("SessionEventLogger")
|
||||
|
||||
|
||||
# #region SessionEventPayload [C:2] [TYPE Class]
|
||||
# @BRIEF Typed input contract for one persisted dataset-review session audit event.
|
||||
@@ -40,26 +37,28 @@ class SessionEventPayload:
|
||||
|
||||
# #region SessionEventLogger [C:4] [TYPE Class]
|
||||
# @BRIEF Persist explicit dataset-review session audit events with meaningful runtime reasoning logs.
|
||||
# @PRE The database session is live and payload identifiers are non-empty.
|
||||
# @POST Returns the committed session event row with a stable identifier and stored detail payload.
|
||||
# @SIDE_EFFECT Writes one audit row to persistence and emits logger.reason/logger.reflect traces.
|
||||
# @DATA_CONTRACT Input[SessionEventPayload] -> Output[SessionEvent]
|
||||
# @RELATION DEPENDS_ON -> [SessionEvent]
|
||||
# @RELATION DEPENDS_ON -> [SessionEventPayload]
|
||||
# @PRE: The database session is live and payload identifiers are non-empty.
|
||||
# @POST: Returns the committed session event row with a stable identifier and stored detail payload.
|
||||
# @SIDE_EFFECT: Writes one audit row to persistence and emits logger.reason/logger.reflect traces.
|
||||
# @DATA_CONTRACT: Input[SessionEventPayload] -> Output[SessionEvent]
|
||||
class SessionEventLogger:
|
||||
# #region SessionEventLogger_init [C:2] [TYPE Function]
|
||||
# @BRIEF Bind a live SQLAlchemy session to the session-event logger.
|
||||
# [DEF:SessionEventLogger_init:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Bind a live SQLAlchemy session to the session-event logger.
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
# #endregion SessionEventLogger_init
|
||||
# [/DEF:SessionEventLogger_init:Function]
|
||||
|
||||
# #region log_event [C:4] [TYPE Function]
|
||||
# @BRIEF Persist one explicit session event row for an owned dataset-review mutation.
|
||||
# @PRE session_id, actor_user_id, event_type, and event_summary are non-empty.
|
||||
# @POST Returns the committed SessionEvent record with normalized detail payload.
|
||||
# @SIDE_EFFECT Inserts and commits one session_events row.
|
||||
# @DATA_CONTRACT Input[SessionEventPayload] -> Output[SessionEvent]
|
||||
# @RELATION DEPENDS_ON -> [SessionEvent]
|
||||
# [DEF:log_event:Function]
|
||||
# @COMPLEXITY: 4
|
||||
# @PURPOSE: Persist one explicit session event row for an owned dataset-review mutation.
|
||||
# @RELATION: [DEPENDS_ON] ->[SessionEvent]
|
||||
# @PRE: session_id, actor_user_id, event_type, and event_summary are non-empty.
|
||||
# @POST: Returns the committed SessionEvent record with normalized detail payload.
|
||||
# @SIDE_EFFECT: Inserts and commits one session_events row.
|
||||
# @DATA_CONTRACT: Input[SessionEventPayload] -> Output[SessionEvent]
|
||||
def log_event(self, payload: SessionEventPayload) -> SessionEvent:
|
||||
with belief_scope("SessionEventLogger.log_event"):
|
||||
session_id = str(payload.session_id or "").strip()
|
||||
@@ -68,22 +67,31 @@ class SessionEventLogger:
|
||||
event_summary = str(payload.event_summary or "").strip()
|
||||
|
||||
if not session_id:
|
||||
log.explore("Session event logging rejected because session_id is empty", error="session_id is empty")
|
||||
logger.explore("Session event logging rejected because session_id is empty")
|
||||
raise ValueError("session_id must be non-empty")
|
||||
if not actor_user_id:
|
||||
log.explore("Session event logging rejected because actor_user_id is empty", error="actor_user_id is empty", payload={"session_id": session_id})
|
||||
logger.explore(
|
||||
"Session event logging rejected because actor_user_id is empty",
|
||||
extra={"session_id": session_id},
|
||||
)
|
||||
raise ValueError("actor_user_id must be non-empty")
|
||||
if not event_type:
|
||||
log.explore("Session event logging rejected because event_type is empty", error="event_type is empty", payload={"session_id": session_id, "actor_user_id": actor_user_id})
|
||||
logger.explore(
|
||||
"Session event logging rejected because event_type is empty",
|
||||
extra={"session_id": session_id, "actor_user_id": actor_user_id},
|
||||
)
|
||||
raise ValueError("event_type must be non-empty")
|
||||
if not event_summary:
|
||||
log.explore("Session event logging rejected because event_summary is empty", error="event_summary is empty", payload={"session_id": session_id, "event_type": event_type})
|
||||
logger.explore(
|
||||
"Session event logging rejected because event_summary is empty",
|
||||
extra={"session_id": session_id, "event_type": event_type},
|
||||
)
|
||||
raise ValueError("event_summary must be non-empty")
|
||||
|
||||
normalized_details = dict(payload.event_details or {})
|
||||
log.reason(
|
||||
logger.reason(
|
||||
"Persisting explicit dataset-review session audit event",
|
||||
payload={
|
||||
extra={
|
||||
"session_id": session_id,
|
||||
"actor_user_id": actor_user_id,
|
||||
"event_type": event_type,
|
||||
@@ -105,20 +113,21 @@ class SessionEventLogger:
|
||||
self.db.commit()
|
||||
self.db.refresh(event)
|
||||
|
||||
log.reflect(
|
||||
logger.reflect(
|
||||
"Dataset-review session audit event persisted",
|
||||
payload={
|
||||
extra={
|
||||
"session_id": session_id,
|
||||
"session_event_id": event.session_event_id,
|
||||
"event_type": event.event_type,
|
||||
},
|
||||
)
|
||||
return event
|
||||
# #endregion log_event
|
||||
# [/DEF:log_event:Function]
|
||||
|
||||
# #region log_for_session [C:2] [TYPE Function]
|
||||
# @BRIEF Convenience wrapper for logging an event directly from a session aggregate root.
|
||||
# @RELATION CALLS -> [SessionEventLogger.log_event]
|
||||
# [DEF:log_for_session:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Convenience wrapper for logging an event directly from a session aggregate root.
|
||||
# @RELATION: [CALLS] ->[SessionEventLogger.log_event]
|
||||
def log_for_session(
|
||||
self,
|
||||
session: DatasetReviewSession,
|
||||
@@ -139,5 +148,7 @@ class SessionEventLogger:
|
||||
event_details=dict(event_details or {}),
|
||||
)
|
||||
)
|
||||
# #endregion log_for_session
|
||||
# [/DEF:log_for_session:Function]
|
||||
# #endregion SessionEventLogger
|
||||
|
||||
# #endregion SessionEventLoggerModule
|
||||
@@ -1,11 +1,6 @@
|
||||
# #region DatasetReviewOrchestrator [C:5] [TYPE Module] [SEMANTICS dataset_review, orchestration, session_lifecycle, intake, recovery]
|
||||
# @BRIEF Coordinate dataset review session startup and lifecycle-safe intake recovery for one authenticated user.
|
||||
# @LAYER Domain
|
||||
# @PRE session mutations must execute inside a persisted session boundary scoped to one authenticated user.
|
||||
# @POST state transitions are persisted atomically and emit observable progress for long-running steps.
|
||||
# @SIDE_EFFECT creates task records, updates session aggregates, triggers upstream Superset calls, persists audit artifacts.
|
||||
# @DATA_CONTRACT Input[SessionCommand] -> Output[DatasetReviewSession | CompiledPreview | DatasetRunContext]
|
||||
# @INVARIANT Launch is blocked unless a current session has no open blocking findings, all launch-sensitive mappings are approved, and a non-stale Superset-generated compiled preview matches the current input fingerprint.
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [DatasetReviewSessionRepository]
|
||||
# @RELATION DEPENDS_ON -> [SemanticSourceResolver]
|
||||
# @RELATION DEPENDS_ON -> [SupersetContextExtractor]
|
||||
@@ -13,8 +8,13 @@
|
||||
# @RELATION DEPENDS_ON -> [TaskManager]
|
||||
# @RELATION DISPATCHES -> [OrchestratorHelpers:Module]
|
||||
# @RELATION DISPATCHES -> [OrchestratorCommands:Module]
|
||||
# @RATIONALE Original 1198-line monolith violated INV_7 (400-line module limit). Decomposed into commands and helpers sub-modules while preserving the orchestrator class as the single entry point.
|
||||
# @REJECTED Keeping all orchestration logic in one file because it exceeded the fractal limit by 3x.
|
||||
# @PRE: session mutations must execute inside a persisted session boundary scoped to one authenticated user.
|
||||
# @POST: state transitions are persisted atomically and emit observable progress for long-running steps.
|
||||
# @SIDE_EFFECT: creates task records, updates session aggregates, triggers upstream Superset calls, persists audit artifacts.
|
||||
# @DATA_CONTRACT: Input[SessionCommand] -> Output[DatasetReviewSession | CompiledPreview | DatasetRunContext]
|
||||
# @INVARIANT: Launch is blocked unless a current session has no open blocking findings, all launch-sensitive mappings are approved, and a non-stale Superset-generated compiled preview matches the current input fingerprint.
|
||||
# @RATIONALE: Original 1198-line monolith violated INV_7 (400-line module limit). Decomposed into commands and helpers sub-modules while preserving the orchestrator class as the single entry point.
|
||||
# @REJECTED: Keeping all orchestration logic in one file because it exceeded the fractal limit by 3x.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -23,8 +23,7 @@ from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, cast
|
||||
|
||||
from src.core.config_manager import ConfigManager
|
||||
from src.core.cot_logger import MarkerLogger
|
||||
from src.core.logger import belief_scope
|
||||
from src.core.logger import belief_scope, logger
|
||||
from src.core.task_manager import TaskManager
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
PreviewCompilationPayload,
|
||||
@@ -88,27 +87,28 @@ from src.services.dataset_review.orchestrator_pkg._helpers import (
|
||||
extract_effective_filter_value,
|
||||
)
|
||||
|
||||
log = MarkerLogger("Orchestrator")
|
||||
logger = cast(Any, logger)
|
||||
|
||||
|
||||
# #region DatasetReviewOrchestrator [C:5] [TYPE Class]
|
||||
# @BRIEF Coordinate safe session startup while preserving cross-user isolation and explicit partial recovery.
|
||||
# @PRE constructor dependencies are valid and tied to the current request/task scope.
|
||||
# @POST orchestrator instance can execute session-scoped mutations for one authenticated user.
|
||||
# @SIDE_EFFECT downstream operations may persist session/profile/finding state and enqueue background tasks.
|
||||
# @DATA_CONTRACT Input[StartSessionCommand] -> Output[StartSessionResult]
|
||||
# @INVARIANT session ownership is preserved on every mutation and recovery remains explicit when partial.
|
||||
# @RELATION DEPENDS_ON -> [DatasetReviewSessionRepository]
|
||||
# @RELATION DEPENDS_ON -> [SupersetContextExtractor]
|
||||
# @RELATION DEPENDS_ON -> [TaskManager]
|
||||
# @RELATION DEPENDS_ON -> [ConfigManager]
|
||||
# @RELATION DEPENDS_ON -> [SemanticSourceResolver]
|
||||
# @RELATION CALLS -> [OrchestratorHelpers:Module]
|
||||
# @PRE: constructor dependencies are valid and tied to the current request/task scope.
|
||||
# @POST: orchestrator instance can execute session-scoped mutations for one authenticated user.
|
||||
# @SIDE_EFFECT: downstream operations may persist session/profile/finding state and enqueue background tasks.
|
||||
# @DATA_CONTRACT: Input[StartSessionCommand] -> Output[StartSessionResult]
|
||||
# @INVARIANT: session ownership is preserved on every mutation and recovery remains explicit when partial.
|
||||
class DatasetReviewOrchestrator:
|
||||
# #region DatasetReviewOrchestrator_init [C:3] [TYPE Function]
|
||||
# @BRIEF Bind repository, config, and task dependencies required by the orchestration boundary.
|
||||
# @PRE repository/config_manager are valid collaborators for the current request scope.
|
||||
# @POST Instance holds collaborator references used by start/preview/launch orchestration methods.
|
||||
# [DEF:DatasetReviewOrchestrator_init:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Bind repository, config, and task dependencies required by the orchestration boundary.
|
||||
# @PRE: repository/config_manager are valid collaborators for the current request scope.
|
||||
# @POST: Instance holds collaborator references used by start/preview/launch orchestration methods.
|
||||
def __init__(
|
||||
self,
|
||||
repository: DatasetReviewSessionRepository,
|
||||
@@ -121,17 +121,18 @@ class DatasetReviewOrchestrator:
|
||||
self.task_manager = task_manager
|
||||
self.semantic_resolver = semantic_resolver or SemanticSourceResolver()
|
||||
|
||||
# #endregion DatasetReviewOrchestrator_init
|
||||
# [/DEF:DatasetReviewOrchestrator_init:Function]
|
||||
|
||||
# #region start_session [C:5] [TYPE Function]
|
||||
# @BRIEF Initialize a new session from a Superset link or dataset selection and trigger context recovery.
|
||||
# @PRE source input is non-empty and environment is accessible.
|
||||
# @POST session exists in persisted storage with intake/recovery state and task linkage when async work is required.
|
||||
# @SIDE_EFFECT persists session and may enqueue recovery task.
|
||||
# @DATA_CONTRACT Input[StartSessionCommand] -> Output[StartSessionResult]
|
||||
# @INVARIANT no cross-user session leakage occurs; session and follow-up task remain owned by the authenticated user.
|
||||
# @RELATION CALLS -> [SupersetContextExtractor.parse_superset_link]
|
||||
# @RELATION CALLS -> [TaskManager.create_task]
|
||||
# [DEF:start_session:Function]
|
||||
# @COMPLEXITY: 5
|
||||
# @PURPOSE: Initialize a new session from a Superset link or dataset selection and trigger context recovery.
|
||||
# @RELATION: CALLS -> [SupersetContextExtractor.parse_superset_link]
|
||||
# @RELATION: CALLS -> [TaskManager.create_task]
|
||||
# @PRE: source input is non-empty and environment is accessible.
|
||||
# @POST: session exists in persisted storage with intake/recovery state and task linkage when async work is required.
|
||||
# @SIDE_EFFECT: persists session and may enqueue recovery task.
|
||||
# @DATA_CONTRACT: Input[StartSessionCommand] -> Output[StartSessionResult]
|
||||
# @INVARIANT: no cross-user session leakage occurs; session and follow-up task remain owned by the authenticated user.
|
||||
def start_session(self, command: StartSessionCommand) -> StartSessionResult:
|
||||
with belief_scope("DatasetReviewOrchestrator.start_session"):
|
||||
normalized_source_kind = str(command.source_kind or "").strip()
|
||||
@@ -139,19 +140,19 @@ class DatasetReviewOrchestrator:
|
||||
normalized_environment_id = str(command.environment_id or "").strip()
|
||||
|
||||
if not normalized_source_input:
|
||||
log.explore("Blocked dataset review session start due to empty source input", error="source_input was empty")
|
||||
logger.explore("Blocked dataset review session start due to empty source input")
|
||||
raise ValueError("source_input must be non-empty")
|
||||
|
||||
if normalized_source_kind not in {"superset_link", "dataset_selection"}:
|
||||
log.explore("Blocked dataset review session start due to unsupported source kind", payload={"source_kind": normalized_source_kind}, error="Unsupported source_kind")
|
||||
logger.explore("Blocked dataset review session start due to unsupported source kind", extra={"source_kind": normalized_source_kind})
|
||||
raise ValueError("source_kind must be 'superset_link' or 'dataset_selection'")
|
||||
|
||||
environment = self.config_manager.get_environment(normalized_environment_id)
|
||||
if environment is None:
|
||||
log.explore("Blocked dataset review session start because environment was not found", payload={"environment_id": normalized_environment_id}, error="Environment not found in config")
|
||||
logger.explore("Blocked dataset review session start because environment was not found", extra={"environment_id": normalized_environment_id})
|
||||
raise ValueError("Environment not found")
|
||||
|
||||
log.reason("Starting dataset review session", payload={"user_id": command.user.id, "environment_id": normalized_environment_id, "source_kind": normalized_source_kind})
|
||||
logger.reason("Starting dataset review session", extra={"user_id": command.user.id, "environment_id": normalized_environment_id, "source_kind": normalized_source_kind})
|
||||
|
||||
parsed_context: Optional[SupersetParsedContext] = None
|
||||
findings: List[ValidationFinding] = []
|
||||
@@ -213,7 +214,7 @@ class DatasetReviewOrchestrator:
|
||||
parsed_context=parsed_context,
|
||||
dataset_ref=dataset_ref,
|
||||
)
|
||||
self.repository.event_log.log_event(
|
||||
self.repository.event_logger.log_event(
|
||||
SessionEventPayload(
|
||||
session_id=persisted_session.session_id,
|
||||
actor_user_id=command.user.id,
|
||||
@@ -255,7 +256,7 @@ class DatasetReviewOrchestrator:
|
||||
self.repository.bump_session_version(persisted_session)
|
||||
self.repository.db.commit()
|
||||
self.repository.db.refresh(persisted_session)
|
||||
self.repository.event_log.log_event(
|
||||
self.repository.event_logger.log_event(
|
||||
SessionEventPayload(
|
||||
session_id=persisted_session.session_id,
|
||||
actor_user_id=command.user.id,
|
||||
@@ -266,29 +267,30 @@ class DatasetReviewOrchestrator:
|
||||
event_details={"task_id": active_task_id},
|
||||
)
|
||||
)
|
||||
log.reason("Linked recovery task to started dataset review session", payload={"session_id": persisted_session.session_id, "task_id": active_task_id})
|
||||
logger.reason("Linked recovery task to started dataset review session", extra={"session_id": persisted_session.session_id, "task_id": active_task_id})
|
||||
|
||||
log.reflect("Dataset review session start completed", payload={"session_id": persisted_session.session_id, "dataset_ref": persisted_session.dataset_ref, "readiness_state": persisted_session.readiness_state.value, "active_task_id": persisted_session.active_task_id, "finding_count": len(findings)})
|
||||
logger.reflect("Dataset review session start completed", extra={"session_id": persisted_session.session_id, "dataset_ref": persisted_session.dataset_ref, "readiness_state": persisted_session.readiness_state.value, "active_task_id": persisted_session.active_task_id, "finding_count": len(findings)})
|
||||
return StartSessionResult(
|
||||
session=persisted_session,
|
||||
parsed_context=parsed_context,
|
||||
findings=findings,
|
||||
)
|
||||
|
||||
# #endregion start_session
|
||||
# [/DEF:start_session:Function]
|
||||
|
||||
# #region prepare_launch_preview [C:4] [TYPE Function]
|
||||
# @BRIEF Assemble effective execution inputs and trigger Superset-side preview compilation.
|
||||
# @PRE all required variables have candidate values or explicitly accepted defaults.
|
||||
# @POST returns preview artifact in pending, ready, failed, or stale state.
|
||||
# @SIDE_EFFECT persists preview attempt and upstream compilation diagnostics.
|
||||
# @DATA_CONTRACT Input[PreparePreviewCommand] -> Output[PreparePreviewResult]
|
||||
# @RELATION CALLS -> [SupersetCompilationAdapter.compile_preview]
|
||||
# [DEF:prepare_launch_preview:Function]
|
||||
# @COMPLEXITY: 4
|
||||
# @PURPOSE: Assemble effective execution inputs and trigger Superset-side preview compilation.
|
||||
# @RELATION: CALLS -> [SupersetCompilationAdapter.compile_preview]
|
||||
# @PRE: all required variables have candidate values or explicitly accepted defaults.
|
||||
# @POST: returns preview artifact in pending, ready, failed, or stale state.
|
||||
# @SIDE_EFFECT: persists preview attempt and upstream compilation diagnostics.
|
||||
# @DATA_CONTRACT: Input[PreparePreviewCommand] -> Output[PreparePreviewResult]
|
||||
def prepare_launch_preview(self, command: PreparePreviewCommand) -> PreparePreviewResult:
|
||||
with belief_scope("DatasetReviewOrchestrator.prepare_launch_preview"):
|
||||
session = self.repository.load_session_detail(command.session_id, command.user.id)
|
||||
if session is None or session.user_id != command.user.id:
|
||||
log.explore("Preview preparation rejected because owned session was not found", payload={"session_id": command.session_id, "user_id": command.user.id}, error="Session not found or access denied")
|
||||
logger.explore("Preview preparation rejected because owned session was not found", extra={"session_id": command.session_id, "user_id": command.user.id})
|
||||
raise ValueError("Session not found")
|
||||
|
||||
if command.expected_version is not None:
|
||||
@@ -304,7 +306,7 @@ class DatasetReviewOrchestrator:
|
||||
execution_snapshot = build_execution_snapshot(session)
|
||||
preview_blockers = execution_snapshot["preview_blockers"]
|
||||
if preview_blockers:
|
||||
log.explore("Preview preparation blocked by incomplete execution context", payload={"session_id": session.session_id, "blocked_reasons": preview_blockers}, error="Preview blockers present")
|
||||
logger.explore("Preview preparation blocked by incomplete execution context", extra={"session_id": session.session_id, "blocked_reasons": preview_blockers})
|
||||
raise ValueError("Preview blocked: " + "; ".join(preview_blockers))
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment)
|
||||
@@ -339,7 +341,7 @@ class DatasetReviewOrchestrator:
|
||||
session.recommended_action = RecommendedAction.GENERATE_SQL_PREVIEW
|
||||
self.repository.db.commit()
|
||||
self.repository.db.refresh(session)
|
||||
self.repository.event_log.log_event(
|
||||
self.repository.event_logger.log_event(
|
||||
SessionEventPayload(
|
||||
session_id=session.session_id,
|
||||
actor_user_id=command.user.id,
|
||||
@@ -351,24 +353,25 @@ class DatasetReviewOrchestrator:
|
||||
)
|
||||
)
|
||||
|
||||
log.reflect("Superset preview preparation completed", payload={"session_id": session.session_id, "preview_id": persisted_preview.preview_id, "preview_status": persisted_preview.preview_status.value})
|
||||
logger.reflect("Superset preview preparation completed", extra={"session_id": session.session_id, "preview_id": persisted_preview.preview_id, "preview_status": persisted_preview.preview_status.value})
|
||||
return PreparePreviewResult(session=session, preview=persisted_preview, blocked_reasons=[])
|
||||
|
||||
# #endregion prepare_launch_preview
|
||||
# [/DEF:prepare_launch_preview:Function]
|
||||
|
||||
# #region launch_dataset [C:5] [TYPE Function]
|
||||
# @BRIEF Start the approved dataset execution through SQL Lab and persist run context for audit/replay.
|
||||
# @PRE session is run-ready and compiled preview is current.
|
||||
# @POST returns persisted run context with SQL Lab session reference and launch outcome.
|
||||
# @SIDE_EFFECT creates SQL Lab execution session and audit snapshot.
|
||||
# @DATA_CONTRACT Input[LaunchDatasetCommand] -> Output[LaunchDatasetResult]
|
||||
# @INVARIANT launch remains blocked unless blocking findings are closed, approvals are satisfied, and the latest preview fingerprint matches current execution inputs.
|
||||
# @RELATION CALLS -> [SupersetCompilationAdapter.create_sql_lab_session]
|
||||
# [DEF:launch_dataset:Function]
|
||||
# @COMPLEXITY: 5
|
||||
# @PURPOSE: Start the approved dataset execution through SQL Lab and persist run context for audit/replay.
|
||||
# @RELATION: CALLS -> [SupersetCompilationAdapter.create_sql_lab_session]
|
||||
# @PRE: session is run-ready and compiled preview is current.
|
||||
# @POST: returns persisted run context with SQL Lab session reference and launch outcome.
|
||||
# @SIDE_EFFECT: creates SQL Lab execution session and audit snapshot.
|
||||
# @DATA_CONTRACT: Input[LaunchDatasetCommand] -> Output[LaunchDatasetResult]
|
||||
# @INVARIANT: launch remains blocked unless blocking findings are closed, approvals are satisfied, and the latest preview fingerprint matches current execution inputs.
|
||||
def launch_dataset(self, command: LaunchDatasetCommand) -> LaunchDatasetResult:
|
||||
with belief_scope("DatasetReviewOrchestrator.launch_dataset"):
|
||||
session = self.repository.load_session_detail(command.session_id, command.user.id)
|
||||
if session is None or session.user_id != command.user.id:
|
||||
log.explore("Launch rejected because owned session was not found", payload={"session_id": command.session_id, "user_id": command.user.id}, error="Session not found or access denied")
|
||||
logger.explore("Launch rejected because owned session was not found", extra={"session_id": command.session_id, "user_id": command.user.id})
|
||||
raise ValueError("Session not found")
|
||||
|
||||
if command.expected_version is not None:
|
||||
@@ -385,7 +388,7 @@ class DatasetReviewOrchestrator:
|
||||
current_preview = get_latest_preview(session)
|
||||
launch_blockers_list = build_launch_blockers(session=session, execution_snapshot=execution_snapshot, preview=current_preview)
|
||||
if launch_blockers_list:
|
||||
log.explore("Launch gate blocked dataset execution", payload={"session_id": session.session_id, "blocked_reasons": launch_blockers_list}, error="Launch blockers present")
|
||||
logger.explore("Launch gate blocked dataset execution", extra={"session_id": session.session_id, "blocked_reasons": launch_blockers_list})
|
||||
raise ValueError("Launch blocked: " + "; ".join(launch_blockers_list))
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment)
|
||||
@@ -402,7 +405,7 @@ class DatasetReviewOrchestrator:
|
||||
launch_status = LaunchStatus.STARTED
|
||||
launch_error = None
|
||||
except Exception as exc:
|
||||
log.explore("SQL Lab launch failed after passing gates", payload={"session_id": session.session_id}, error=str(exc))
|
||||
logger.explore("SQL Lab launch failed after passing gates", extra={"session_id": session.session_id, "error": str(exc)})
|
||||
sql_lab_session_ref = "unavailable"
|
||||
launch_status = LaunchStatus.FAILED
|
||||
launch_error = str(exc)
|
||||
@@ -438,7 +441,7 @@ class DatasetReviewOrchestrator:
|
||||
session.recommended_action = RecommendedAction.EXPORT_OUTPUTS
|
||||
self.repository.db.commit()
|
||||
self.repository.db.refresh(session)
|
||||
self.repository.event_log.log_event(
|
||||
self.repository.event_logger.log_event(
|
||||
SessionEventPayload(
|
||||
session_id=session.session_id,
|
||||
actor_user_id=command.user.id,
|
||||
@@ -450,16 +453,17 @@ class DatasetReviewOrchestrator:
|
||||
)
|
||||
)
|
||||
|
||||
log.reflect("Dataset launch orchestration completed with audited run context", payload={"session_id": session.session_id, "run_context_id": persisted_run_context.run_context_id, "launch_status": persisted_run_context.launch_status.value})
|
||||
logger.reflect("Dataset launch orchestration completed with audited run context", extra={"session_id": session.session_id, "run_context_id": persisted_run_context.run_context_id, "launch_status": persisted_run_context.launch_status.value})
|
||||
return LaunchDatasetResult(session=session, run_context=persisted_run_context, blocked_reasons=[])
|
||||
|
||||
# #endregion launch_dataset
|
||||
# [/DEF:launch_dataset:Function]
|
||||
|
||||
# #region _build_recovery_bootstrap [C:4] [TYPE Function]
|
||||
# @BRIEF Recover and materialize initial imported filters, template variables, and draft execution mappings after session creation.
|
||||
# @PRE session belongs to the just-created review aggregate and parsed_context was produced for the same environment scope.
|
||||
# @POST Returns bootstrap imported filters, template variables, execution mappings, and updated findings without persisting them directly.
|
||||
# @SIDE_EFFECT Performs Superset reads through the extractor and may append warning findings for incomplete recovery.
|
||||
# [DEF:_build_recovery_bootstrap:Function]
|
||||
# @COMPLEXITY: 4
|
||||
# @PURPOSE: Recover and materialize initial imported filters, template variables, and draft execution mappings after session creation.
|
||||
# @PRE: session belongs to the just-created review aggregate and parsed_context was produced for the same environment scope.
|
||||
# @POST: Returns bootstrap imported filters, template variables, execution mappings, and updated findings without persisting them directly.
|
||||
# @SIDE_EFFECT: Performs Superset reads through the extractor and may append warning findings for incomplete recovery.
|
||||
def _build_recovery_bootstrap(
|
||||
self,
|
||||
environment,
|
||||
@@ -525,7 +529,7 @@ class DatasetReviewOrchestrator:
|
||||
caused_by_ref="dataset_template_variable_discovery_failed",
|
||||
)
|
||||
)
|
||||
log.explore("Template variable discovery failed during session bootstrap", payload={"session_id": session_record.session_id, "dataset_id": session_record.dataset_id}, error=str(exc))
|
||||
logger.explore("Template variable discovery failed during session bootstrap", extra={"session_id": session_record.session_id, "dataset_id": session_record.dataset_id, "error": str(exc)})
|
||||
|
||||
filter_lookup = {str(f.filter_name or "").strip().lower(): f for f in imported_filters if str(f.filter_name or "").strip()}
|
||||
for tv in template_variables:
|
||||
@@ -552,13 +556,14 @@ class DatasetReviewOrchestrator:
|
||||
|
||||
return imported_filters, template_variables, execution_mappings, findings
|
||||
|
||||
# #endregion _build_recovery_bootstrap
|
||||
# [/DEF:_build_recovery_bootstrap:Function]
|
||||
|
||||
# #region _enqueue_recovery_task [C:3] [TYPE Function]
|
||||
# @BRIEF Link session start to observable async recovery when task infrastructure is available.
|
||||
# @PRE session is already persisted.
|
||||
# @POST returns task identifier when a task could be enqueued, otherwise None.
|
||||
# @SIDE_EFFECT may create one background task for progressive recovery.
|
||||
# [DEF:_enqueue_recovery_task:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Link session start to observable async recovery when task infrastructure is available.
|
||||
# @PRE: session is already persisted.
|
||||
# @POST: returns task identifier when a task could be enqueued, otherwise None.
|
||||
# @SIDE_EFFECT: may create one background task for progressive recovery.
|
||||
def _enqueue_recovery_task(
|
||||
self,
|
||||
command: StartSessionCommand,
|
||||
@@ -567,7 +572,7 @@ class DatasetReviewOrchestrator:
|
||||
) -> Optional[str]:
|
||||
session_record = cast(Any, session)
|
||||
if self.task_manager is None:
|
||||
log.reason("Dataset review session started without task manager; continuing synchronously", payload={"session_id": session_record.session_id})
|
||||
logger.reason("Dataset review session started without task manager; continuing synchronously", extra={"session_id": session_record.session_id})
|
||||
return None
|
||||
|
||||
task_params: Dict[str, Any] = {
|
||||
@@ -584,19 +589,21 @@ class DatasetReviewOrchestrator:
|
||||
|
||||
create_task = getattr(self.task_manager, "create_task", None)
|
||||
if create_task is None:
|
||||
log.explore("Task manager has no create_task method; skipping recovery enqueue", error="create_task method not found")
|
||||
logger.explore("Task manager has no create_task method; skipping recovery enqueue")
|
||||
return None
|
||||
|
||||
try:
|
||||
task_object = create_task(plugin_id="dataset-review-recovery", params=task_params)
|
||||
except TypeError:
|
||||
log.explore("Recovery task enqueue skipped because task manager create_task contract is incompatible", payload={"session_id": session_record.session_id}, error="TypeError from create_task")
|
||||
logger.explore("Recovery task enqueue skipped because task manager create_task contract is incompatible", extra={"session_id": session_record.session_id})
|
||||
return None
|
||||
|
||||
task_id = getattr(task_object, "id", None)
|
||||
return str(task_id) if task_id else None
|
||||
|
||||
# #endregion _enqueue_recovery_task
|
||||
# [/DEF:_enqueue_recovery_task:Function]
|
||||
|
||||
|
||||
# #endregion DatasetReviewOrchestrator
|
||||
|
||||
# #endregion DatasetReviewOrchestrator
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# #region OrchestratorCommands [C:2] [TYPE Module]
|
||||
# @BRIEF Typed command and result dataclasses for dataset review orchestration boundary.
|
||||
# @LAYER Domain
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [DatasetReviewModels]
|
||||
# @RELATION DEPENDS_ON -> [SupersetContextExtractor]
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# #region OrchestratorHelpers [C:4] [TYPE Module]
|
||||
# @BRIEF Pure helper methods extracted from DatasetReviewOrchestrator for INV_7 compliance — snapshot, blockers, fingerprint, recovery bootstrap.
|
||||
# @LAYER Domain
|
||||
# @PRE Caller provides a loaded session aggregate with hydrated child collections.
|
||||
# @POST Helper results are deterministic and do not mutate persistence directly.
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [DatasetReviewModels]
|
||||
# @RELATION DEPENDS_ON -> [SupersetContextExtractor]
|
||||
# @PRE: Caller provides a loaded session aggregate with hydrated child collections.
|
||||
# @POST: Helper results are deterministic and do not mutate persistence directly.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,7 +13,7 @@ import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, cast
|
||||
|
||||
from src.core.cot_logger import MarkerLogger
|
||||
from src.core.logger import belief_scope, logger
|
||||
from src.models.dataset_review import (
|
||||
ApprovalState,
|
||||
CompiledPreview,
|
||||
@@ -37,7 +37,8 @@ from src.models.dataset_review import (
|
||||
BusinessSummarySource,
|
||||
)
|
||||
|
||||
log = MarkerLogger("OrchestratorHelpers")
|
||||
logger = cast(Any, logger)
|
||||
|
||||
|
||||
# #region parse_dataset_selection [C:2] [TYPE Function]
|
||||
# @BRIEF Normalize dataset-selection payload into canonical session references.
|
||||
@@ -103,8 +104,8 @@ def build_initial_profile(
|
||||
|
||||
# #region build_partial_recovery_findings [C:3] [TYPE Function]
|
||||
# @BRIEF Project partial Superset intake recovery into explicit findings without blocking session usability.
|
||||
# @PRE parsed_context.partial_recovery is true.
|
||||
# @POST Returns warning-level findings that preserve usable but incomplete state.
|
||||
# @PRE: parsed_context.partial_recovery is true.
|
||||
# @POST: Returns warning-level findings that preserve usable but incomplete state.
|
||||
def build_partial_recovery_findings(parsed_context: Any) -> List[ValidationFinding]:
|
||||
findings: List[ValidationFinding] = []
|
||||
for unresolved_ref in getattr(parsed_context, "unresolved_references", []):
|
||||
@@ -146,8 +147,8 @@ def extract_effective_filter_value(
|
||||
|
||||
# #region build_execution_snapshot [C:4] [TYPE Function]
|
||||
# @BRIEF Build effective filters, template params, approvals, and fingerprint for preview and launch gating.
|
||||
# @PRE Session aggregate includes imported filters, template variables, and current execution mappings.
|
||||
# @POST Returns deterministic execution snapshot for current session state without mutating persistence.
|
||||
# @PRE: Session aggregate includes imported filters, template variables, and current execution mappings.
|
||||
# @POST: Returns deterministic execution snapshot for current session state without mutating persistence.
|
||||
def build_execution_snapshot(session: DatasetReviewSession) -> Dict[str, Any]:
|
||||
session_record = cast(Any, session)
|
||||
filter_lookup = {
|
||||
@@ -275,8 +276,8 @@ def build_execution_snapshot(session: DatasetReviewSession) -> Dict[str, Any]:
|
||||
|
||||
# #region build_launch_blockers [C:3] [TYPE Function]
|
||||
# @BRIEF Enforce launch gates from findings, approvals, and current preview truth.
|
||||
# @PRE execution_snapshot was computed from current session state.
|
||||
# @POST Returns explicit blocker codes for every unmet launch invariant.
|
||||
# @PRE: execution_snapshot was computed from current session state.
|
||||
# @POST: Returns explicit blocker codes for every unmet launch invariant.
|
||||
def build_launch_blockers(
|
||||
session: DatasetReviewSession,
|
||||
execution_snapshot: Dict[str, Any],
|
||||
|
||||
@@ -26,16 +26,18 @@ from src.services.dataset_review.repositories.session_repository import (
|
||||
DatasetReviewSessionVersionConflictError,
|
||||
)
|
||||
|
||||
# #region SessionRepositoryTests [C:2] [TYPE Module]
|
||||
# @BRIEF Unit tests for DatasetReviewSessionRepository.
|
||||
# @RELATION BELONGS_TO -> [SrcRoot]
|
||||
# [DEF:SessionRepositoryTests:Module]
|
||||
# @RELATION: BELONGS_TO -> SrcRoot
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Unit tests for DatasetReviewSessionRepository.
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_session():
|
||||
# #region db_session [C:2] [TYPE Function]
|
||||
# @BRIEF Build isolated in-memory SQLAlchemy session seeded with baseline user/environment rows.
|
||||
# @RELATION BINDS_TO -> [SessionRepositoryTests]
|
||||
# [DEF:db_session:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Build isolated in-memory SQLAlchemy session seeded with baseline user/environment rows.
|
||||
# @RELATION: BINDS_TO -> [SessionRepositoryTests]
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
@@ -55,11 +57,11 @@ def db_session():
|
||||
session.close()
|
||||
|
||||
|
||||
# #endregion db_session
|
||||
# [/DEF:db_session:Function]
|
||||
|
||||
|
||||
# #region test_create_session [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [SessionRepositoryTests]
|
||||
# [DEF:test_create_session:Function]
|
||||
# @RELATION: BINDS_TO -> SessionRepositoryTests
|
||||
def test_create_session(db_session):
|
||||
# @PURPOSE: Verify session creation and persistence.
|
||||
repo = DatasetReviewSessionRepository(db_session)
|
||||
@@ -82,12 +84,12 @@ def test_create_session(db_session):
|
||||
assert loaded.version == 0
|
||||
|
||||
|
||||
# #endregion test_create_session
|
||||
# [/DEF:test_create_session:Function]
|
||||
|
||||
|
||||
# #region test_require_session_version_conflict [TYPE Function]
|
||||
# @BRIEF Verify optimistic-lock conflict is raised when caller version is stale.
|
||||
# @RELATION BINDS_TO -> [SessionRepositoryTests]
|
||||
# [DEF:test_require_session_version_conflict:Function]
|
||||
# @RELATION: BINDS_TO -> SessionRepositoryTests
|
||||
# @PURPOSE: Verify optimistic-lock conflict is raised when caller version is stale.
|
||||
def test_require_session_version_conflict(db_session):
|
||||
repo = DatasetReviewSessionRepository(db_session)
|
||||
session = DatasetReviewSession(
|
||||
@@ -107,12 +109,12 @@ def test_require_session_version_conflict(db_session):
|
||||
assert exc_info.value.actual_version == 2
|
||||
|
||||
|
||||
# #endregion test_require_session_version_conflict
|
||||
# [/DEF:test_require_session_version_conflict:Function]
|
||||
|
||||
|
||||
# #region test_bump_session_version_updates_last_activity [TYPE Function]
|
||||
# @BRIEF Verify repository version bump increments monotonically and refreshes last activity.
|
||||
# @RELATION BINDS_TO -> [SessionRepositoryTests]
|
||||
# [DEF:test_bump_session_version_updates_last_activity:Function]
|
||||
# @RELATION: BINDS_TO -> SessionRepositoryTests
|
||||
# @PURPOSE: Verify repository version bump increments monotonically and refreshes last activity.
|
||||
def test_bump_session_version_updates_last_activity(db_session):
|
||||
repo = DatasetReviewSessionRepository(db_session)
|
||||
session = DatasetReviewSession(
|
||||
@@ -132,12 +134,12 @@ def test_bump_session_version_updates_last_activity(db_session):
|
||||
assert session.last_activity_at >= before_activity
|
||||
|
||||
|
||||
# #endregion test_bump_session_version_updates_last_activity
|
||||
# [/DEF:test_bump_session_version_updates_last_activity:Function]
|
||||
|
||||
|
||||
# #region test_save_recovery_state_preserves_raw_value_masked_flag [TYPE Function]
|
||||
# @BRIEF Verify imported-filter masking metadata persists with recovery bootstrap state.
|
||||
# @RELATION BINDS_TO -> [SessionRepositoryTests]
|
||||
# [DEF:test_save_recovery_state_preserves_raw_value_masked_flag:Function]
|
||||
# @RELATION: BINDS_TO -> SessionRepositoryTests
|
||||
# @PURPOSE: Verify imported-filter masking metadata persists with recovery bootstrap state.
|
||||
def test_save_recovery_state_preserves_raw_value_masked_flag(db_session):
|
||||
repo = DatasetReviewSessionRepository(db_session)
|
||||
session = DatasetReviewSession(
|
||||
@@ -173,11 +175,11 @@ def test_save_recovery_state_preserves_raw_value_masked_flag(db_session):
|
||||
assert updated.version == 1
|
||||
|
||||
|
||||
# #endregion test_save_recovery_state_preserves_raw_value_masked_flag
|
||||
# [/DEF:test_save_recovery_state_preserves_raw_value_masked_flag:Function]
|
||||
|
||||
|
||||
# #region test_load_session_detail_ownership [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [SessionRepositoryTests]
|
||||
# [DEF:test_load_session_detail_ownership:Function]
|
||||
# @RELATION: BINDS_TO -> SessionRepositoryTests
|
||||
def test_load_session_detail_ownership(db_session):
|
||||
# @PURPOSE: Verify ownership enforcement in detail loading.
|
||||
repo = DatasetReviewSessionRepository(db_session)
|
||||
@@ -199,11 +201,11 @@ def test_load_session_detail_ownership(db_session):
|
||||
assert loaded_wrong is None
|
||||
|
||||
|
||||
# #endregion test_load_session_detail_ownership
|
||||
# [/DEF:test_load_session_detail_ownership:Function]
|
||||
|
||||
|
||||
# #region test_load_session_detail_collaborator [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [SessionRepositoryTests]
|
||||
# [DEF:test_load_session_detail_collaborator:Function]
|
||||
# @RELATION: BINDS_TO -> SessionRepositoryTests
|
||||
def test_load_session_detail_collaborator(db_session):
|
||||
# @PURPOSE: Verify collaborator access in detail loading.
|
||||
repo = DatasetReviewSessionRepository(db_session)
|
||||
@@ -236,11 +238,11 @@ def test_load_session_detail_collaborator(db_session):
|
||||
assert loaded.session_id == session.session_id
|
||||
|
||||
|
||||
# #endregion test_load_session_detail_collaborator
|
||||
# [/DEF:test_load_session_detail_collaborator:Function]
|
||||
|
||||
|
||||
# #region test_save_preview_marks_stale [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [SessionRepositoryTests]
|
||||
# [DEF:test_save_preview_marks_stale:Function]
|
||||
# @RELATION: BINDS_TO -> SessionRepositoryTests
|
||||
def test_save_preview_marks_stale(db_session):
|
||||
# @PURPOSE: Verify that saving a new preview marks old ones as stale.
|
||||
repo = DatasetReviewSessionRepository(db_session)
|
||||
@@ -269,12 +271,12 @@ def test_save_preview_marks_stale(db_session):
|
||||
assert session.last_preview_id == p2.preview_id
|
||||
|
||||
|
||||
# #endregion test_save_preview_marks_stale
|
||||
# [/DEF:test_save_preview_marks_stale:Function]
|
||||
|
||||
|
||||
# #region test_save_preview_increments_session_version_once_per_call [TYPE Function]
|
||||
# @BRIEF Verify preview persistence itself contributes exactly one optimistic-lock version increment so higher orchestration layers do not need to bump again for the same preview mutation.
|
||||
# @RELATION BINDS_TO -> [SessionRepositoryTests]
|
||||
# [DEF:test_save_preview_increments_session_version_once_per_call:Function]
|
||||
# @RELATION: BINDS_TO -> SessionRepositoryTests
|
||||
# @PURPOSE: Verify preview persistence itself contributes exactly one optimistic-lock version increment so higher orchestration layers do not need to bump again for the same preview mutation.
|
||||
def test_save_preview_increments_session_version_once_per_call(db_session):
|
||||
repo = DatasetReviewSessionRepository(db_session)
|
||||
session = DatasetReviewSession(
|
||||
@@ -311,11 +313,11 @@ def test_save_preview_increments_session_version_once_per_call(db_session):
|
||||
assert session.version == 2
|
||||
|
||||
|
||||
# #endregion test_save_preview_increments_session_version_once_per_call
|
||||
# [/DEF:test_save_preview_increments_session_version_once_per_call:Function]
|
||||
|
||||
|
||||
# #region test_save_profile_and_findings [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [SessionRepositoryTests]
|
||||
# [DEF:test_save_profile_and_findings:Function]
|
||||
# @RELATION: BINDS_TO -> SessionRepositoryTests
|
||||
def test_save_profile_and_findings(db_session):
|
||||
# @PURPOSE: Verify persistence of profile and findings.
|
||||
repo = DatasetReviewSessionRepository(db_session)
|
||||
@@ -372,12 +374,12 @@ def test_save_profile_and_findings(db_session):
|
||||
assert final_session.version == 2
|
||||
|
||||
|
||||
# #endregion test_save_profile_and_findings
|
||||
# [/DEF:test_save_profile_and_findings:Function]
|
||||
|
||||
|
||||
# #region test_save_profile_and_findings_rejects_stale_concurrent_write [TYPE Function]
|
||||
# @BRIEF Verify repository save path translates concurrent stale session writes into deterministic optimistic-lock conflicts.
|
||||
# @RELATION BINDS_TO -> [SessionRepositoryTests]
|
||||
# [DEF:test_save_profile_and_findings_rejects_stale_concurrent_write:Function]
|
||||
# @RELATION: BINDS_TO -> SessionRepositoryTests
|
||||
# @PURPOSE: Verify repository save path translates concurrent stale session writes into deterministic optimistic-lock conflicts.
|
||||
def test_save_profile_and_findings_rejects_stale_concurrent_write(tmp_path: Path):
|
||||
db_path = tmp_path / "dataset_review_session_repository.sqlite"
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
@@ -472,11 +474,11 @@ def test_save_profile_and_findings_rejects_stale_concurrent_write(tmp_path: Path
|
||||
writer_b.close()
|
||||
|
||||
|
||||
# #endregion test_save_profile_and_findings_rejects_stale_concurrent_write
|
||||
# [/DEF:test_save_profile_and_findings_rejects_stale_concurrent_write:Function]
|
||||
|
||||
|
||||
# #region test_save_run_context [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [SessionRepositoryTests]
|
||||
# [DEF:test_save_run_context:Function]
|
||||
# @RELATION: BINDS_TO -> SessionRepositoryTests
|
||||
def test_save_run_context(db_session):
|
||||
# @PURPOSE: Verify saving of run context.
|
||||
repo = DatasetReviewSessionRepository(db_session)
|
||||
@@ -507,12 +509,12 @@ def test_save_run_context(db_session):
|
||||
assert session.last_run_context_id == rc.run_context_id
|
||||
|
||||
|
||||
# #endregion test_save_run_context
|
||||
# [/DEF:test_save_run_context:Function]
|
||||
|
||||
|
||||
# #region test_ensure_dataset_review_session_columns_adds_missing_legacy_columns [TYPE Function]
|
||||
# @BRIEF Verify additive dataset review migration creates missing legacy columns for session and imported-filter tables without dropping rows.
|
||||
# @RELATION BINDS_TO -> [SessionRepositoryTests]
|
||||
# [DEF:test_ensure_dataset_review_session_columns_adds_missing_legacy_columns:Function]
|
||||
# @RELATION: BINDS_TO -> SessionRepositoryTests
|
||||
# @PURPOSE: Verify additive dataset review migration creates missing legacy columns for session and imported-filter tables without dropping rows.
|
||||
def test_ensure_dataset_review_session_columns_adds_missing_legacy_columns():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
with engine.begin() as connection:
|
||||
@@ -674,11 +676,11 @@ def test_ensure_dataset_review_session_columns_adds_missing_legacy_columns():
|
||||
assert raw_value_masked in (False, 0)
|
||||
|
||||
|
||||
# #endregion test_ensure_dataset_review_session_columns_adds_missing_legacy_columns
|
||||
# [/DEF:test_ensure_dataset_review_session_columns_adds_missing_legacy_columns:Function]
|
||||
|
||||
|
||||
# #region test_list_sessions_for_user [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [SessionRepositoryTests]
|
||||
# [DEF:test_list_sessions_for_user:Function]
|
||||
# @RELATION: BINDS_TO -> SessionRepositoryTests
|
||||
def test_list_sessions_for_user(db_session):
|
||||
# @PURPOSE: Verify listing of sessions by user.
|
||||
repo = DatasetReviewSessionRepository(db_session)
|
||||
@@ -712,5 +714,5 @@ def test_list_sessions_for_user(db_session):
|
||||
assert all(s.user_id == "user1" for s in sessions)
|
||||
|
||||
|
||||
# #endregion test_list_sessions_for_user
|
||||
# #endregion SessionRepositoryTests
|
||||
# [/DEF:test_list_sessions_for_user:Function]
|
||||
# [/DEF:SessionRepositoryTests:Module]
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# #region SessionRepositoryMutations [C:4] [TYPE Module]
|
||||
# @BRIEF Persistence mutation operations for dataset review session aggregates — profile/findings, recovery state, preview, run context.
|
||||
# @LAYER Domain
|
||||
# @PRE All mutations execute within authenticated request or task scope.
|
||||
# @POST Session aggregate writes preserve ownership and version semantics.
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [DatasetReviewModels]
|
||||
# @RELATION DEPENDS_ON -> [SessionEventLogger]
|
||||
# @PRE: All mutations execute within authenticated request or task scope.
|
||||
# @POST: Session aggregate writes preserve ownership and version semantics.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,8 +13,7 @@ from typing import Any, List, Optional, cast
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.core.cot_logger import MarkerLogger
|
||||
from src.core.logger import belief_scope
|
||||
from src.core.logger import belief_scope, logger
|
||||
from src.models.dataset_review import (
|
||||
ClarificationQuestion,
|
||||
ClarificationSession,
|
||||
@@ -32,14 +31,14 @@ from src.models.dataset_review import (
|
||||
)
|
||||
from src.services.dataset_review.event_logger import SessionEventLogger
|
||||
|
||||
log = MarkerLogger("SessionMutations")
|
||||
logger = cast(Any, logger)
|
||||
|
||||
|
||||
# #region save_profile_and_findings [C:4] [TYPE Function]
|
||||
# @BRIEF Persist profile state and replace validation findings for an owned session in one transaction.
|
||||
# @PRE session_id belongs to user_id and the supplied profile/findings belong to the same aggregate scope.
|
||||
# @POST stored profile matches the current session and findings are replaced by the supplied collection.
|
||||
# @SIDE_EFFECT updates profile rows, deletes stale findings, inserts current findings, and commits the transaction.
|
||||
# @PRE: session_id belongs to user_id and the supplied profile/findings belong to the same aggregate scope.
|
||||
# @POST: stored profile matches the current session and findings are replaced by the supplied collection.
|
||||
# @SIDE_EFFECT: updates profile rows, deletes stale findings, inserts current findings, and commits the transaction.
|
||||
def save_profile_and_findings(
|
||||
db: Session,
|
||||
event_logger: SessionEventLogger,
|
||||
@@ -56,7 +55,7 @@ def save_profile_and_findings(
|
||||
session = get_owned_session(session_id, user_id)
|
||||
if expected_version is not None:
|
||||
require_session_version(session, expected_version)
|
||||
log.reason("Persisting dataset profile and replacing validation findings", payload={"session_id": session_id, "user_id": user_id, "has_profile": bool(profile), "findings_count": len(findings)})
|
||||
logger.reason("Persisting dataset profile and replacing validation findings", extra={"session_id": session_id, "user_id": user_id, "has_profile": bool(profile), "findings_count": len(findings)})
|
||||
|
||||
if profile:
|
||||
existing_profile = db.query(DatasetProfile).filter_by(session_id=session_id).first()
|
||||
@@ -70,7 +69,7 @@ def save_profile_and_findings(
|
||||
db.add(finding)
|
||||
|
||||
commit_session_mutation(session, expected_version=expected_version)
|
||||
log.reflect("Dataset profile and validation findings committed", payload={"session_id": session.session_id, "user_id": user_id, "findings_count": len(findings)})
|
||||
logger.reflect("Dataset profile and validation findings committed", extra={"session_id": session.session_id, "user_id": user_id, "findings_count": len(findings)})
|
||||
|
||||
from src.services.dataset_review.repositories.session_repository import DatasetReviewSessionRepository
|
||||
return session
|
||||
@@ -81,9 +80,9 @@ def save_profile_and_findings(
|
||||
|
||||
# #region save_recovery_state [C:4] [TYPE Function]
|
||||
# @BRIEF Persist imported filters, template variables, and initial execution mappings for one owned session.
|
||||
# @PRE session_id belongs to user_id.
|
||||
# @POST Recovery state persisted to database.
|
||||
# @SIDE_EFFECT Writes to database.
|
||||
# @PRE: session_id belongs to user_id.
|
||||
# @POST: Recovery state persisted to database.
|
||||
# @SIDE_EFFECT: Writes to database.
|
||||
def save_recovery_state(
|
||||
db: Session,
|
||||
get_owned_session,
|
||||
@@ -101,7 +100,7 @@ def save_recovery_state(
|
||||
session = get_owned_session(session_id, user_id)
|
||||
if expected_version is not None:
|
||||
require_session_version(session, expected_version)
|
||||
log.reason("Persisting dataset review recovery bootstrap state", payload={"session_id": session_id, "user_id": user_id, "imported_filters_count": len(imported_filters), "template_variables_count": len(template_variables), "execution_mappings_count": len(execution_mappings)})
|
||||
logger.reason("Persisting dataset review recovery bootstrap state", extra={"session_id": session_id, "user_id": user_id, "imported_filters_count": len(imported_filters), "template_variables_count": len(template_variables), "execution_mappings_count": len(execution_mappings)})
|
||||
|
||||
db.query(ExecutionMapping).filter(ExecutionMapping.session_id == session_id).delete()
|
||||
db.query(TemplateVariable).filter(TemplateVariable.session_id == session_id).delete()
|
||||
@@ -119,7 +118,7 @@ def save_recovery_state(
|
||||
db.add(em)
|
||||
|
||||
commit_session_mutation(session, expected_version=expected_version)
|
||||
log.reflect("Dataset review recovery bootstrap state committed", payload={"session_id": session.session_id, "user_id": user_id})
|
||||
logger.reflect("Dataset review recovery bootstrap state committed", extra={"session_id": session.session_id, "user_id": user_id})
|
||||
return load_session_detail_fn(session_id, user_id)
|
||||
|
||||
|
||||
@@ -128,9 +127,9 @@ def save_recovery_state(
|
||||
|
||||
# #region save_preview [C:3] [TYPE Function]
|
||||
# @BRIEF Persist a preview snapshot and mark prior session previews stale.
|
||||
# @PRE session_id belongs to user_id and preview is prepared for the same session aggregate.
|
||||
# @POST preview is persisted and the session points to the latest preview identifier.
|
||||
# @SIDE_EFFECT updates prior preview statuses, inserts a preview row, mutates the parent session, and commits.
|
||||
# @PRE: session_id belongs to user_id and preview is prepared for the same session aggregate.
|
||||
# @POST: preview is persisted and the session points to the latest preview identifier.
|
||||
# @SIDE_EFFECT: updates prior preview statuses, inserts a preview row, mutates the parent session, and commits.
|
||||
def save_preview(
|
||||
db: Session,
|
||||
get_owned_session,
|
||||
@@ -146,7 +145,7 @@ def save_preview(
|
||||
session_record = cast(Any, session)
|
||||
if expected_version is not None:
|
||||
require_session_version(session, expected_version)
|
||||
log.reason("Persisting compiled preview and staling previous preview snapshots", payload={"session_id": session_id, "user_id": user_id})
|
||||
logger.reason("Persisting compiled preview and staling previous preview snapshots", extra={"session_id": session_id, "user_id": user_id})
|
||||
|
||||
db.query(CompiledPreview).filter(CompiledPreview.session_id == session_id).update({"preview_status": "stale"})
|
||||
db.add(preview)
|
||||
@@ -154,7 +153,7 @@ def save_preview(
|
||||
session_record.last_preview_id = preview.preview_id
|
||||
|
||||
commit_session_mutation(session, refresh_targets=[preview], expected_version=expected_version)
|
||||
log.reflect("Compiled preview committed as latest session preview", payload={"session_id": session.session_id, "preview_id": preview.preview_id})
|
||||
logger.reflect("Compiled preview committed as latest session preview", extra={"session_id": session.session_id, "preview_id": preview.preview_id})
|
||||
return preview
|
||||
|
||||
|
||||
@@ -163,9 +162,9 @@ def save_preview(
|
||||
|
||||
# #region save_run_context [C:3] [TYPE Function]
|
||||
# @BRIEF Persist an immutable launch audit snapshot for an owned session.
|
||||
# @PRE session_id belongs to user_id and run_context targets the same aggregate.
|
||||
# @POST run context is persisted and linked as the latest launch snapshot for the session.
|
||||
# @SIDE_EFFECT inserts a run-context row, mutates the parent session pointer, and commits.
|
||||
# @PRE: session_id belongs to user_id and run_context targets the same aggregate.
|
||||
# @POST: run context is persisted and linked as the latest launch snapshot for the session.
|
||||
# @SIDE_EFFECT: inserts a run-context row, mutates the parent session pointer, and commits.
|
||||
def save_run_context(
|
||||
db: Session,
|
||||
get_owned_session,
|
||||
@@ -181,14 +180,14 @@ def save_run_context(
|
||||
session_record = cast(Any, session)
|
||||
if expected_version is not None:
|
||||
require_session_version(session, expected_version)
|
||||
log.reason("Persisting dataset run context audit snapshot", payload={"session_id": session_id, "user_id": user_id})
|
||||
logger.reason("Persisting dataset run context audit snapshot", extra={"session_id": session_id, "user_id": user_id})
|
||||
|
||||
db.add(run_context)
|
||||
db.flush()
|
||||
session_record.last_run_context_id = run_context.run_context_id
|
||||
|
||||
commit_session_mutation(session, refresh_targets=[run_context], expected_version=expected_version)
|
||||
log.reflect("Dataset run context committed as latest launch snapshot", payload={"session_id": session.session_id, "run_context_id": run_context.run_context_id})
|
||||
logger.reflect("Dataset run context committed as latest launch snapshot", extra={"session_id": session.session_id, "run_context_id": run_context.run_context_id})
|
||||
return run_context
|
||||
|
||||
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
# #region DatasetReviewSessionRepository [C:5] [TYPE Module]
|
||||
# @BRIEF Persist and retrieve dataset review session aggregates, including readiness, findings, semantic decisions, clarification state, previews, and run contexts.
|
||||
# @LAYER Domain
|
||||
# @PRE repository operations execute within authenticated request or task scope.
|
||||
# @POST session aggregate reads are structurally consistent and writes preserve ownership and version semantics.
|
||||
# @SIDE_EFFECT reads and writes SQLAlchemy-backed session aggregates.
|
||||
# @DATA_CONTRACT Input[SessionMutation] -> Output[PersistedSessionAggregate]
|
||||
# @INVARIANT answers, mapping approvals, preview artifacts, and launch snapshots are never attributed to the wrong user or session.
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [DatasetReviewSession]
|
||||
# @RELATION DEPENDS_ON -> [DatasetProfile]
|
||||
# @RELATION DEPENDS_ON -> [ValidationFinding]
|
||||
# @RELATION DEPENDS_ON -> [CompiledPreview]
|
||||
# @RELATION DISPATCHES -> [SessionRepositoryMutations:Module]
|
||||
# @RATIONALE Original 627-line file exceeded INV_7 (400-line module limit). Extracted mutation operations into _mutations sub-module.
|
||||
# @REJECTED Keeping all repository operations in one file because it exceeded the fractal limit.
|
||||
# @PRE: repository operations execute within authenticated request or task scope.
|
||||
# @POST: session aggregate reads are structurally consistent and writes preserve ownership and version semantics.
|
||||
# @SIDE_EFFECT: reads and writes SQLAlchemy-backed session aggregates.
|
||||
# @DATA_CONTRACT: Input[SessionMutation] -> Output[PersistedSessionAggregate]
|
||||
# @INVARIANT: answers, mapping approvals, preview artifacts, and launch snapshots are never attributed to the wrong user or session.
|
||||
# @RATIONALE: Original 627-line file exceeded INV_7 (400-line module limit). Extracted mutation operations into _mutations sub-module.
|
||||
# @REJECTED: Keeping all repository operations in one file because it exceeded the fractal limit.
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional, List
|
||||
from typing import Any, Optional, List, cast
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy.orm.exc import StaleDataError
|
||||
from src.core.cot_logger import MarkerLogger
|
||||
from src.core.logger import belief_scope
|
||||
from src.models.dataset_review import (
|
||||
ClarificationQuestion,
|
||||
ClarificationSession,
|
||||
@@ -36,9 +34,10 @@ from src.models.dataset_review import (
|
||||
SessionEvent,
|
||||
TemplateVariable,
|
||||
)
|
||||
from src.core.logger import belief_scope, logger
|
||||
from src.services.dataset_review.event_logger import SessionEventLogger
|
||||
|
||||
log = MarkerLogger("SessionRepository")
|
||||
logger = cast(Any, logger)
|
||||
|
||||
|
||||
# #region DatasetReviewSessionVersionConflictError [C:2] [TYPE Class]
|
||||
@@ -58,93 +57,99 @@ class DatasetReviewSessionVersionConflictError(ValueError):
|
||||
|
||||
# #region DatasetReviewSessionRepository [C:4] [TYPE Class]
|
||||
# @BRIEF Enforce ownership-scoped persistence and retrieval for dataset review session aggregates.
|
||||
# @PRE constructor receives a live SQLAlchemy session and callers provide authenticated user scope.
|
||||
# @POST repository methods return ownership-scoped aggregates or persisted child records without changing domain meaning.
|
||||
# @SIDE_EFFECT mutates and queries the persistence layer through the injected database session.
|
||||
# @RELATION DEPENDS_ON -> [DatasetReviewSession]
|
||||
# @RELATION DEPENDS_ON -> [SessionEventLogger]
|
||||
# @PRE: constructor receives a live SQLAlchemy session and callers provide authenticated user scope.
|
||||
# @POST: repository methods return ownership-scoped aggregates or persisted child records without changing domain meaning.
|
||||
# @SIDE_EFFECT: mutates and queries the persistence layer through the injected database session.
|
||||
class DatasetReviewSessionRepository:
|
||||
# #region init_repo [C:2] [TYPE Function]
|
||||
# @BRIEF Bind one live SQLAlchemy session to the repository instance.
|
||||
# @PRE db_session is not None
|
||||
# @POST Repository instance initialized with valid session
|
||||
# [DEF:init_repo:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Bind one live SQLAlchemy session to the repository instance.
|
||||
# @PRE: db_session is not None
|
||||
# @POST: Repository instance initialized with valid session
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.event_logger = SessionEventLogger(db)
|
||||
|
||||
# #endregion init_repo
|
||||
# [/DEF:init_repo:Function]
|
||||
|
||||
# #region get_owned_session [C:3] [TYPE Function]
|
||||
# @BRIEF Resolve one owner-scoped dataset review session for mutation paths.
|
||||
# @PRE session_id and user_id are non-empty identifiers from the authenticated ownership scope.
|
||||
# @POST returns the owned session or raises a deterministic access error.
|
||||
# [DEF:get_owned_session:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Resolve one owner-scoped dataset review session for mutation paths.
|
||||
# @PRE: session_id and user_id are non-empty identifiers from the authenticated ownership scope.
|
||||
# @POST: returns the owned session or raises a deterministic access error.
|
||||
def _get_owned_session(self, session_id: str, user_id: str) -> DatasetReviewSession:
|
||||
with belief_scope("DatasetReviewSessionRepository.get_owned_session"):
|
||||
log.reason("Resolving owner-scoped dataset review session", payload={"session_id": session_id, "user_id": user_id})
|
||||
logger.reason("Resolving owner-scoped dataset review session", extra={"session_id": session_id, "user_id": user_id})
|
||||
session = (
|
||||
self.db.query(DatasetReviewSession)
|
||||
.filter(DatasetReviewSession.session_id == session_id, DatasetReviewSession.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
if not session:
|
||||
log.explore("Owner-scoped dataset review session lookup failed", payload={"session_id": session_id, "user_id": user_id}, error="Session not found or access denied")
|
||||
logger.explore("Owner-scoped dataset review session lookup failed", extra={"session_id": session_id, "user_id": user_id})
|
||||
raise ValueError("Session not found or access denied")
|
||||
log.reflect("Owner-scoped dataset review session resolved", payload={"session_id": session.session_id})
|
||||
logger.reflect("Owner-scoped dataset review session resolved", extra={"session_id": session.session_id})
|
||||
return session
|
||||
|
||||
# #endregion get_owned_session
|
||||
# [/DEF:get_owned_session:Function]
|
||||
|
||||
# #region create_sess [C:3] [TYPE Function]
|
||||
# @BRIEF Persist an initial dataset review session shell.
|
||||
# @POST session is committed, refreshed, and returned with persisted identifiers.
|
||||
# [DEF:create_sess:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Persist an initial dataset review session shell.
|
||||
# @POST: session is committed, refreshed, and returned with persisted identifiers.
|
||||
def create_session(self, session: DatasetReviewSession) -> DatasetReviewSession:
|
||||
with belief_scope("DatasetReviewSessionRepository.create_session"):
|
||||
log.reason("Persisting dataset review session shell", payload={"user_id": session.user_id, "environment_id": session.environment_id})
|
||||
logger.reason("Persisting dataset review session shell", extra={"user_id": session.user_id, "environment_id": session.environment_id})
|
||||
self.db.add(session)
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
log.reflect("Dataset review session shell persisted", payload={"session_id": session.session_id})
|
||||
logger.reflect("Dataset review session shell persisted", extra={"session_id": session.session_id})
|
||||
return session
|
||||
|
||||
# #endregion create_sess
|
||||
# [/DEF:create_sess:Function]
|
||||
|
||||
# #region require_session_version [C:3] [TYPE Function]
|
||||
# @BRIEF Enforce optimistic-lock version matching before a session mutation is persisted.
|
||||
# @POST returns the same session when versions match; otherwise raises deterministic conflict error.
|
||||
# [DEF:require_session_version:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Enforce optimistic-lock version matching before a session mutation is persisted.
|
||||
# @POST: returns the same session when versions match; otherwise raises deterministic conflict error.
|
||||
def require_session_version(self, session: DatasetReviewSession, expected_version: int) -> DatasetReviewSession:
|
||||
with belief_scope("DatasetReviewSessionRepository.require_session_version"):
|
||||
actual_version = int(getattr(session, "version", 0) or 0)
|
||||
log.reason("Checking optimistic-lock version", payload={"session_id": session.session_id, "expected_version": expected_version, "actual_version": actual_version})
|
||||
logger.reason("Checking optimistic-lock version", extra={"session_id": session.session_id, "expected_version": expected_version, "actual_version": actual_version})
|
||||
if actual_version != expected_version:
|
||||
log.explore("Rejected mutation due to stale session version", payload={"session_id": session.session_id, "expected_version": expected_version, "actual_version": actual_version}, error="Optimistic lock version mismatch")
|
||||
logger.explore("Rejected mutation due to stale session version", extra={"session_id": session.session_id, "expected_version": expected_version, "actual_version": actual_version})
|
||||
raise DatasetReviewSessionVersionConflictError(str(session.session_id), expected_version, actual_version)
|
||||
log.reflect("Optimistic-lock version accepted", payload={"session_id": session.session_id, "version": actual_version})
|
||||
logger.reflect("Optimistic-lock version accepted", extra={"session_id": session.session_id, "version": actual_version})
|
||||
return session
|
||||
|
||||
# #endregion require_session_version
|
||||
# [/DEF:require_session_version:Function]
|
||||
|
||||
# #region bump_session_version [C:2] [TYPE Function]
|
||||
# @BRIEF Increment optimistic-lock version after a successful session mutation is assembled.
|
||||
# @POST session version increments monotonically.
|
||||
# [DEF:bump_session_version:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Increment optimistic-lock version after a successful session mutation is assembled.
|
||||
# @POST: session version increments monotonically.
|
||||
def bump_session_version(self, session: DatasetReviewSession) -> int:
|
||||
with belief_scope("DatasetReviewSessionRepository.bump_session_version"):
|
||||
next_version = int(getattr(session, "version", 0) or 0) + 1
|
||||
setattr(session, "version", next_version)
|
||||
session.last_activity_at = datetime.utcnow()
|
||||
log.reflect("Prepared incremented session version", payload={"session_id": session.session_id, "version": next_version})
|
||||
logger.reflect("Prepared incremented session version", extra={"session_id": session.session_id, "version": next_version})
|
||||
return next_version
|
||||
|
||||
# #endregion bump_session_version
|
||||
# [/DEF:bump_session_version:Function]
|
||||
|
||||
# #region commit_session_mutation [C:4] [TYPE Function]
|
||||
# @BRIEF Commit one prepared session mutation and translate stale writes into deterministic conflicts.
|
||||
# @POST session mutation is committed with one version increment or a deterministic conflict error is raised.
|
||||
# [DEF:commit_session_mutation:Function]
|
||||
# @COMPLEXITY: 4
|
||||
# @PURPOSE: Commit one prepared session mutation and translate stale writes into deterministic conflicts.
|
||||
# @POST: session mutation is committed with one version increment or a deterministic conflict error is raised.
|
||||
def commit_session_mutation(
|
||||
self, session: DatasetReviewSession, *, refresh_targets: Optional[List[Any]] = None, expected_version: Optional[int] = None,
|
||||
) -> DatasetReviewSession:
|
||||
with belief_scope("DatasetReviewSessionRepository.commit_session_mutation"):
|
||||
observed_version = int(expected_version if expected_version is not None else getattr(session, "version", 0) or 0)
|
||||
log.reason("Committing session mutation with optimistic lock", payload={"session_id": session.session_id, "observed_version": observed_version})
|
||||
logger.reason("Committing session mutation with optimistic lock", extra={"session_id": session.session_id, "observed_version": observed_version})
|
||||
self.bump_session_version(session)
|
||||
try:
|
||||
self.db.commit()
|
||||
@@ -152,22 +157,23 @@ class DatasetReviewSessionRepository:
|
||||
self.db.rollback()
|
||||
actual_version_row = self.db.query(DatasetReviewSession.version).filter(DatasetReviewSession.session_id == session.session_id).first()
|
||||
actual_version = int(actual_version_row[0] or 0) if actual_version_row else 0
|
||||
log.explore("Session commit rejected by optimistic lock", payload={"session_id": session.session_id, "expected_version": observed_version, "actual_version": actual_version}, error="StaleDataError on session commit")
|
||||
logger.explore("Session commit rejected by optimistic lock", extra={"session_id": session.session_id, "expected_version": observed_version, "actual_version": actual_version})
|
||||
raise DatasetReviewSessionVersionConflictError(session.session_id, observed_version, actual_version) from exc
|
||||
self.db.refresh(session)
|
||||
for target in refresh_targets or []:
|
||||
self.db.refresh(target)
|
||||
log.reflect("Session mutation committed", payload={"session_id": session.session_id, "version": getattr(session, "version", None)})
|
||||
logger.reflect("Session mutation committed", extra={"session_id": session.session_id, "version": getattr(session, "version", None)})
|
||||
return session
|
||||
|
||||
# #endregion commit_session_mutation
|
||||
# [/DEF:commit_session_mutation:Function]
|
||||
|
||||
# #region load_detail [C:3] [TYPE Function]
|
||||
# @BRIEF Return the full session aggregate for API and frontend resume flows.
|
||||
# @POST Returns SessionDetail with all fields populated or None.
|
||||
# [DEF:load_detail:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Return the full session aggregate for API and frontend resume flows.
|
||||
# @POST: Returns SessionDetail with all fields populated or None.
|
||||
def load_session_detail(self, session_id: str, user_id: str) -> Optional[DatasetReviewSession]:
|
||||
with belief_scope("DatasetReviewSessionRepository.load_session_detail"):
|
||||
log.reason("Loading dataset review session detail", payload={"session_id": session_id, "user_id": user_id})
|
||||
logger.reason("Loading dataset review session detail", extra={"session_id": session_id, "user_id": user_id})
|
||||
session = (
|
||||
self.db.query(DatasetReviewSession)
|
||||
.outerjoin(SessionCollaborator, DatasetReviewSession.session_id == SessionCollaborator.session_id)
|
||||
@@ -190,14 +196,15 @@ class DatasetReviewSessionRepository:
|
||||
.filter(or_(DatasetReviewSession.user_id == user_id, SessionCollaborator.user_id == user_id))
|
||||
.first()
|
||||
)
|
||||
log.reflect("Session detail lookup completed", payload={"session_id": session_id, "found": bool(session)})
|
||||
logger.reflect("Session detail lookup completed", extra={"session_id": session_id, "found": bool(session)})
|
||||
return session
|
||||
|
||||
# #endregion load_detail
|
||||
# [/DEF:load_detail:Function]
|
||||
|
||||
# #region save_profile_and_findings [C:4] [TYPE Function]
|
||||
# @BRIEF Persist profile state and replace validation findings for an owned session.
|
||||
# @POST stored profile matches the current session and findings are replaced.
|
||||
# [DEF:save_profile_and_findings:Function]
|
||||
# @COMPLEXITY: 4
|
||||
# @PURPOSE: Persist profile state and replace validation findings for an owned session.
|
||||
# @POST: stored profile matches the current session and findings are replaced.
|
||||
def save_profile_and_findings(
|
||||
self, session_id: str, user_id: str, profile: DatasetProfile, findings: List[ValidationFinding], expected_version: Optional[int] = None,
|
||||
) -> DatasetReviewSession:
|
||||
@@ -207,10 +214,11 @@ class DatasetReviewSessionRepository:
|
||||
self.commit_session_mutation, session_id, user_id, profile, findings, expected_version,
|
||||
)
|
||||
|
||||
# #endregion save_profile_and_findings
|
||||
# [/DEF:save_profile_and_findings:Function]
|
||||
|
||||
# #region save_recovery_state [C:3] [TYPE Function]
|
||||
# @BRIEF Persist imported filters, template variables, and initial execution mappings.
|
||||
# [DEF:save_recovery_state:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Persist imported filters, template variables, and initial execution mappings.
|
||||
def save_recovery_state(
|
||||
self, session_id: str, user_id: str, imported_filters: List[ImportedFilter],
|
||||
template_variables: List[TemplateVariable], execution_mappings: List[ExecutionMapping],
|
||||
@@ -223,10 +231,11 @@ class DatasetReviewSessionRepository:
|
||||
session_id, user_id, imported_filters, template_variables, execution_mappings, expected_version,
|
||||
)
|
||||
|
||||
# #endregion save_recovery_state
|
||||
# [/DEF:save_recovery_state:Function]
|
||||
|
||||
# #region save_preview [C:3] [TYPE Function]
|
||||
# @BRIEF Persist a preview snapshot and mark prior session previews stale.
|
||||
# [DEF:save_preview:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Persist a preview snapshot and mark prior session previews stale.
|
||||
def save_preview(
|
||||
self, session_id: str, user_id: str, preview: CompiledPreview, expected_version: Optional[int] = None,
|
||||
) -> CompiledPreview:
|
||||
@@ -236,10 +245,11 @@ class DatasetReviewSessionRepository:
|
||||
self.commit_session_mutation, session_id, user_id, preview, expected_version,
|
||||
)
|
||||
|
||||
# #endregion save_preview
|
||||
# [/DEF:save_preview:Function]
|
||||
|
||||
# #region save_run_context [C:3] [TYPE Function]
|
||||
# @BRIEF Persist an immutable launch audit snapshot for an owned session.
|
||||
# [DEF:save_run_context:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Persist an immutable launch audit snapshot for an owned session.
|
||||
def save_run_context(
|
||||
self, session_id: str, user_id: str, run_context: DatasetRunContext, expected_version: Optional[int] = None,
|
||||
) -> DatasetRunContext:
|
||||
@@ -249,23 +259,26 @@ class DatasetReviewSessionRepository:
|
||||
self.commit_session_mutation, session_id, user_id, run_context, expected_version,
|
||||
)
|
||||
|
||||
# #endregion save_run_context
|
||||
# [/DEF:save_run_context:Function]
|
||||
|
||||
# #region list_user_sess [C:2] [TYPE Function]
|
||||
# @BRIEF List review sessions owned by a specific user ordered by most recent update.
|
||||
# [DEF:list_user_sess:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: List review sessions owned by a specific user ordered by most recent update.
|
||||
def list_sessions_for_user(self, user_id: str) -> List[DatasetReviewSession]:
|
||||
with belief_scope("DatasetReviewSessionRepository.list_sessions_for_user"):
|
||||
log.reason("Listing dataset review sessions for owner scope", payload={"user_id": user_id})
|
||||
logger.reason("Listing dataset review sessions for owner scope", extra={"user_id": user_id})
|
||||
sessions = (
|
||||
self.db.query(DatasetReviewSession)
|
||||
.filter(DatasetReviewSession.user_id == user_id)
|
||||
.order_by(DatasetReviewSession.updated_at.desc())
|
||||
.all()
|
||||
)
|
||||
log.reflect("Session list assembled", payload={"user_id": user_id, "session_count": len(sessions)})
|
||||
logger.reflect("Session list assembled", extra={"user_id": user_id, "session_count": len(sessions)})
|
||||
return sessions
|
||||
|
||||
# #endregion list_user_sess
|
||||
# [/DEF:list_user_sess:Function]
|
||||
|
||||
|
||||
# #endregion DatasetReviewSessionRepository
|
||||
|
||||
# #endregion DatasetReviewSessionRepository
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# #region SemanticSourceResolver [C:4] [TYPE Module] [SEMANTICS dataset_review, semantic_resolution, dictionary, trusted_sources, ranking]
|
||||
# @BRIEF Resolve and rank semantic candidates from trusted dictionary-like sources before any inferred fallback.
|
||||
# @LAYER Domain
|
||||
# @PRE selected source and target field set must be known.
|
||||
# @POST candidate ranking follows the configured confidence hierarchy and unresolved fuzzy matches remain reviewable.
|
||||
# @SIDE_EFFECT may create conflict findings and semantic candidate records.
|
||||
# @INVARIANT Manual overrides are never silently replaced by imported, inferred, or AI-generated values.
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [LLMProviderService]
|
||||
# @RELATION DEPENDS_ON -> [SemanticSource]
|
||||
# @RELATION DEPENDS_ON -> [SemanticFieldEntry]
|
||||
# @RELATION DEPENDS_ON -> [SemanticCandidate]
|
||||
# @PRE: selected source and target field set must be known.
|
||||
# @POST: candidate ranking follows the configured confidence hierarchy and unresolved fuzzy matches remain reviewable.
|
||||
# @SIDE_EFFECT: may create conflict findings and semantic candidate records.
|
||||
# @INVARIANT: Manual overrides are never silently replaced by imported, inferred, or AI-generated values.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -17,8 +17,7 @@ from dataclasses import dataclass, field
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional
|
||||
|
||||
from src.core.cot_logger import MarkerLogger
|
||||
from src.core.logger import belief_scope
|
||||
from src.core.logger import belief_scope, logger
|
||||
from src.models.dataset_review import (
|
||||
CandidateMatchType,
|
||||
CandidateStatus,
|
||||
@@ -27,8 +26,6 @@ from src.models.dataset_review import (
|
||||
)
|
||||
# #endregion imports
|
||||
|
||||
log = MarkerLogger("SemanticSourceResolver")
|
||||
|
||||
|
||||
# #region DictionaryResolutionResult [C:2] [TYPE Class]
|
||||
# @BRIEF Carries field-level dictionary resolution output with explicit review and partial-recovery state.
|
||||
@@ -43,26 +40,28 @@ class DictionaryResolutionResult:
|
||||
|
||||
# #region SemanticSourceResolver [C:4] [TYPE Class]
|
||||
# @BRIEF Resolve semantic candidates from trusted sources while preserving manual locks and confidence ordering.
|
||||
# @PRE source payload and target field collection are provided by the caller.
|
||||
# @POST result contains confidence-ranked candidates and does not overwrite manual locks implicitly.
|
||||
# @SIDE_EFFECT emits semantic trace logs for ranking and fallback decisions.
|
||||
# @RELATION DEPENDS_ON -> [SemanticFieldEntry]
|
||||
# @RELATION DEPENDS_ON -> [SemanticCandidate]
|
||||
# @PRE: source payload and target field collection are provided by the caller.
|
||||
# @POST: result contains confidence-ranked candidates and does not overwrite manual locks implicitly.
|
||||
# @SIDE_EFFECT: emits semantic trace logs for ranking and fallback decisions.
|
||||
class SemanticSourceResolver:
|
||||
# #region resolve_from_file [C:2] [TYPE Function]
|
||||
# @BRIEF Normalize uploaded semantic file records into field-level candidates.
|
||||
# [DEF:resolve_from_file:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Normalize uploaded semantic file records into field-level candidates.
|
||||
def resolve_from_file(self, source_payload: Mapping[str, Any], fields: Iterable[Mapping[str, Any]]) -> DictionaryResolutionResult:
|
||||
return DictionaryResolutionResult(source_ref=str(source_payload.get("source_ref") or "uploaded_file"))
|
||||
# #endregion resolve_from_file
|
||||
# [/DEF:resolve_from_file:Function]
|
||||
|
||||
# #region resolve_from_dictionary [C:4] [TYPE Function]
|
||||
# @BRIEF Resolve candidates from connected tabular dictionary sources.
|
||||
# @PRE dictionary source exists and fields contain stable field_name values.
|
||||
# @POST returns confidence-ranked candidates where exact dictionary matches outrank fuzzy matches and unresolved fields stay explicit.
|
||||
# @SIDE_EFFECT emits belief-state logs describing trusted-match and partial-recovery outcomes.
|
||||
# @DATA_CONTRACT Input[source_payload:Mapping,fields:Iterable] -> Output[DictionaryResolutionResult]
|
||||
# @RELATION DEPENDS_ON -> [SemanticFieldEntry]
|
||||
# @RELATION DEPENDS_ON -> [SemanticCandidate]
|
||||
# [DEF:resolve_from_dictionary:Function]
|
||||
# @COMPLEXITY: 4
|
||||
# @PURPOSE: Resolve candidates from connected tabular dictionary sources.
|
||||
# @RELATION: [DEPENDS_ON] ->[SemanticFieldEntry]
|
||||
# @RELATION: [DEPENDS_ON] ->[SemanticCandidate]
|
||||
# @PRE: dictionary source exists and fields contain stable field_name values.
|
||||
# @POST: returns confidence-ranked candidates where exact dictionary matches outrank fuzzy matches and unresolved fields stay explicit.
|
||||
# @SIDE_EFFECT: emits belief-state logs describing trusted-match and partial-recovery outcomes.
|
||||
# @DATA_CONTRACT: Input[source_payload:Mapping,fields:Iterable] -> Output[DictionaryResolutionResult]
|
||||
def resolve_from_dictionary(
|
||||
self,
|
||||
source_payload: Mapping[str, Any],
|
||||
@@ -73,16 +72,19 @@ class SemanticSourceResolver:
|
||||
dictionary_rows = source_payload.get("rows")
|
||||
|
||||
if not source_ref:
|
||||
log.explore("Dictionary semantic source is missing source_ref", error="No source_ref in dictionary source")
|
||||
logger.explore("Dictionary semantic source is missing source_ref")
|
||||
raise ValueError("Dictionary semantic source must include source_ref")
|
||||
|
||||
if not isinstance(dictionary_rows, list) or not dictionary_rows:
|
||||
log.explore("Dictionary semantic source has no usable rows", error="Dictionary rows is empty or not a list", payload={"source_ref": source_ref})
|
||||
logger.explore(
|
||||
"Dictionary semantic source has no usable rows",
|
||||
extra={"source_ref": source_ref},
|
||||
)
|
||||
raise ValueError("Dictionary semantic source must include non-empty rows")
|
||||
|
||||
log.reason(
|
||||
logger.reason(
|
||||
"Resolving semantics from trusted dictionary source",
|
||||
payload={"source_ref": source_ref, "row_count": len(dictionary_rows)},
|
||||
extra={"source_ref": source_ref, "row_count": len(dictionary_rows)},
|
||||
)
|
||||
|
||||
normalized_rows = [self._normalize_dictionary_row(row) for row in dictionary_rows if isinstance(row, Mapping)]
|
||||
@@ -102,9 +104,9 @@ class SemanticSourceResolver:
|
||||
|
||||
is_locked = bool(raw_field.get("is_locked"))
|
||||
if is_locked:
|
||||
log.reason(
|
||||
logger.reason(
|
||||
"Preserving manual lock during dictionary resolution",
|
||||
payload={"field_name": field_name},
|
||||
extra={"field_name": field_name},
|
||||
)
|
||||
resolved_fields.append(
|
||||
{
|
||||
@@ -124,9 +126,9 @@ class SemanticSourceResolver:
|
||||
candidates: List[Dict[str, Any]] = []
|
||||
|
||||
if exact_match is not None:
|
||||
log.reason(
|
||||
logger.reason(
|
||||
"Resolved exact dictionary match",
|
||||
payload={"field_name": field_name, "source_ref": source_ref},
|
||||
extra={"field_name": field_name, "source_ref": source_ref},
|
||||
)
|
||||
candidates.append(
|
||||
self._build_candidate_payload(
|
||||
@@ -162,7 +164,10 @@ class SemanticSourceResolver:
|
||||
"status": "unresolved",
|
||||
}
|
||||
)
|
||||
log.explore("No trusted dictionary match found for field", error="No dictionary match found for field", payload={"field_name": field_name, "source_ref": source_ref})
|
||||
logger.explore(
|
||||
"No trusted dictionary match found for field",
|
||||
extra={"field_name": field_name, "source_ref": source_ref},
|
||||
)
|
||||
continue
|
||||
|
||||
ranked_candidates = self.rank_candidates(candidates)
|
||||
@@ -194,9 +199,9 @@ class SemanticSourceResolver:
|
||||
unresolved_fields=unresolved_fields,
|
||||
partial_recovery=bool(unresolved_fields),
|
||||
)
|
||||
log.reflect(
|
||||
logger.reflect(
|
||||
"Dictionary resolution completed",
|
||||
payload={
|
||||
extra={
|
||||
"source_ref": source_ref,
|
||||
"resolved_fields": len(resolved_fields),
|
||||
"unresolved_fields": len(unresolved_fields),
|
||||
@@ -204,21 +209,23 @@ class SemanticSourceResolver:
|
||||
},
|
||||
)
|
||||
return result
|
||||
# #endregion resolve_from_dictionary
|
||||
# [/DEF:resolve_from_dictionary:Function]
|
||||
|
||||
# #region resolve_from_reference_dataset [C:2] [TYPE Function]
|
||||
# @BRIEF Reuse semantic metadata from trusted Superset datasets.
|
||||
# [DEF:resolve_from_reference_dataset:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Reuse semantic metadata from trusted Superset datasets.
|
||||
def resolve_from_reference_dataset(
|
||||
self,
|
||||
source_payload: Mapping[str, Any],
|
||||
fields: Iterable[Mapping[str, Any]],
|
||||
) -> DictionaryResolutionResult:
|
||||
return DictionaryResolutionResult(source_ref=str(source_payload.get("source_ref") or "reference_dataset"))
|
||||
# #endregion resolve_from_reference_dataset
|
||||
# [/DEF:resolve_from_reference_dataset:Function]
|
||||
|
||||
# #region rank_candidates [C:2] [TYPE Function]
|
||||
# @BRIEF Apply confidence ordering and determine best candidate per field.
|
||||
# @RELATION DEPENDS_ON -> [SemanticCandidate]
|
||||
# [DEF:rank_candidates:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Apply confidence ordering and determine best candidate per field.
|
||||
# @RELATION: [DEPENDS_ON] ->[SemanticCandidate]
|
||||
def rank_candidates(self, candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
ranked = sorted(
|
||||
candidates,
|
||||
@@ -231,30 +238,33 @@ class SemanticSourceResolver:
|
||||
for index, candidate in enumerate(ranked, start=1):
|
||||
candidate["candidate_rank"] = index
|
||||
return ranked
|
||||
# #endregion rank_candidates
|
||||
# [/DEF:rank_candidates:Function]
|
||||
|
||||
# #region detect_conflicts [C:2] [TYPE Function]
|
||||
# @BRIEF Mark competing candidate sets that require explicit user review.
|
||||
# [DEF:detect_conflicts:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Mark competing candidate sets that require explicit user review.
|
||||
def detect_conflicts(self, candidates: List[Dict[str, Any]]) -> bool:
|
||||
return len(candidates) > 1
|
||||
# #endregion detect_conflicts
|
||||
# [/DEF:detect_conflicts:Function]
|
||||
|
||||
# #region apply_field_decision [C:2] [TYPE Function]
|
||||
# @BRIEF Accept, reject, or manually override a field-level semantic value.
|
||||
# [DEF:apply_field_decision:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Accept, reject, or manually override a field-level semantic value.
|
||||
def apply_field_decision(self, field_state: Mapping[str, Any], decision: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
merged = dict(field_state)
|
||||
merged.update(decision)
|
||||
return merged
|
||||
# #endregion apply_field_decision
|
||||
# [/DEF:apply_field_decision:Function]
|
||||
|
||||
# #region propagate_source_version_update [C:4] [TYPE Function]
|
||||
# @BRIEF Propagate a semantic source version change to unlocked field entries without silently overwriting manual or locked values.
|
||||
# @PRE source is persisted and fields belong to the same session aggregate.
|
||||
# @POST unlocked fields linked to the source carry the new source version and are marked reviewable; manual or locked fields keep their active values untouched.
|
||||
# @SIDE_EFFECT mutates in-memory field state for the caller to persist.
|
||||
# @DATA_CONTRACT Input[SemanticSource,List[SemanticFieldEntry]] -> Output[Dict[str,int]]
|
||||
# @RELATION DEPENDS_ON -> [SemanticSource]
|
||||
# @RELATION DEPENDS_ON -> [SemanticFieldEntry]
|
||||
# [DEF:propagate_source_version_update:Function]
|
||||
# @COMPLEXITY: 4
|
||||
# @PURPOSE: Propagate a semantic source version change to unlocked field entries without silently overwriting manual or locked values.
|
||||
# @RELATION: [DEPENDS_ON] ->[SemanticSource]
|
||||
# @RELATION: [DEPENDS_ON] ->[SemanticFieldEntry]
|
||||
# @PRE: source is persisted and fields belong to the same session aggregate.
|
||||
# @POST: unlocked fields linked to the source carry the new source version and are marked reviewable; manual or locked fields keep their active values untouched.
|
||||
# @SIDE_EFFECT: mutates in-memory field state for the caller to persist.
|
||||
# @DATA_CONTRACT: Input[SemanticSource,List[SemanticFieldEntry]] -> Output[Dict[str,int]]
|
||||
def propagate_source_version_update(
|
||||
self,
|
||||
source: SemanticSource,
|
||||
@@ -264,7 +274,10 @@ class SemanticSourceResolver:
|
||||
source_id = str(source.source_id or "").strip()
|
||||
source_version = str(source.source_version or "").strip()
|
||||
if not source_id or not source_version:
|
||||
log.explore("Semantic source version propagation rejected due to incomplete source metadata", error="Source metadata incomplete (missing source_id or source_version)", payload={"source_id": source_id, "source_version": source_version})
|
||||
logger.explore(
|
||||
"Semantic source version propagation rejected due to incomplete source metadata",
|
||||
extra={"source_id": source_id, "source_version": source_version},
|
||||
)
|
||||
raise ValueError("Semantic source must provide source_id and source_version")
|
||||
|
||||
propagated = 0
|
||||
@@ -283,9 +296,9 @@ class SemanticSourceResolver:
|
||||
field.has_conflict = bool(getattr(field, "has_conflict", False))
|
||||
propagated += 1
|
||||
|
||||
log.reflect(
|
||||
logger.reflect(
|
||||
"Semantic source version propagation completed",
|
||||
payload={
|
||||
extra={
|
||||
"source_id": source_id,
|
||||
"source_version": source_version,
|
||||
"propagated": propagated,
|
||||
@@ -298,10 +311,11 @@ class SemanticSourceResolver:
|
||||
"preserved_locked": preserved_locked,
|
||||
"untouched": untouched,
|
||||
}
|
||||
# #endregion propagate_source_version_update
|
||||
# [/DEF:propagate_source_version_update:Function]
|
||||
|
||||
# #region _normalize_dictionary_row [C:2] [TYPE Function]
|
||||
# @BRIEF Normalize one dictionary row into a consistent lookup structure.
|
||||
# [DEF:_normalize_dictionary_row:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Normalize one dictionary row into a consistent lookup structure.
|
||||
def _normalize_dictionary_row(self, row: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
field_name = (
|
||||
row.get("field_name")
|
||||
@@ -317,10 +331,11 @@ class SemanticSourceResolver:
|
||||
"description": row.get("description"),
|
||||
"display_format": row.get("display_format") or row.get("format"),
|
||||
}
|
||||
# #endregion _normalize_dictionary_row
|
||||
# [/DEF:_normalize_dictionary_row:Function]
|
||||
|
||||
# #region _find_fuzzy_matches [C:2] [TYPE Function]
|
||||
# @BRIEF Produce confidence-scored fuzzy matches while keeping them reviewable.
|
||||
# [DEF:_find_fuzzy_matches:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Produce confidence-scored fuzzy matches while keeping them reviewable.
|
||||
def _find_fuzzy_matches(self, field_name: str, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
normalized_target = self._normalize_key(field_name)
|
||||
fuzzy_matches: List[Dict[str, Any]] = []
|
||||
@@ -334,10 +349,11 @@ class SemanticSourceResolver:
|
||||
fuzzy_matches.append({"row": row, "score": round(score, 3)})
|
||||
fuzzy_matches.sort(key=lambda item: item["score"], reverse=True)
|
||||
return fuzzy_matches[:3]
|
||||
# #endregion _find_fuzzy_matches
|
||||
# [/DEF:_find_fuzzy_matches:Function]
|
||||
|
||||
# #region _build_candidate_payload [C:2] [TYPE Function]
|
||||
# @BRIEF Project normalized dictionary rows into semantic candidate payloads.
|
||||
# [DEF:_build_candidate_payload:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Project normalized dictionary rows into semantic candidate payloads.
|
||||
def _build_candidate_payload(
|
||||
self,
|
||||
rank: int,
|
||||
@@ -354,10 +370,11 @@ class SemanticSourceResolver:
|
||||
"proposed_display_format": row.get("display_format"),
|
||||
"status": CandidateStatus.PROPOSED.value,
|
||||
}
|
||||
# #endregion _build_candidate_payload
|
||||
# [/DEF:_build_candidate_payload:Function]
|
||||
|
||||
# #region _match_priority [C:2] [TYPE Function]
|
||||
# @BRIEF Encode trusted-confidence ordering so exact dictionary reuse beats fuzzy invention.
|
||||
# [DEF:_match_priority:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Encode trusted-confidence ordering so exact dictionary reuse beats fuzzy invention.
|
||||
def _match_priority(self, match_type: Optional[str]) -> int:
|
||||
priority = {
|
||||
CandidateMatchType.EXACT.value: 0,
|
||||
@@ -366,11 +383,14 @@ class SemanticSourceResolver:
|
||||
CandidateMatchType.GENERATED.value: 3,
|
||||
}
|
||||
return priority.get(str(match_type or ""), 99)
|
||||
# #endregion _match_priority
|
||||
# [/DEF:_match_priority:Function]
|
||||
|
||||
# #region _normalize_key [C:2] [TYPE Function]
|
||||
# @BRIEF Normalize field identifiers for stable exact/fuzzy comparisons.
|
||||
# [DEF:_normalize_key:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Normalize field identifiers for stable exact/fuzzy comparisons.
|
||||
def _normalize_key(self, value: str) -> str:
|
||||
return "".join(ch for ch in str(value or "").strip().lower() if ch.isalnum() or ch == "_")
|
||||
# #endregion _normalize_key
|
||||
# [/DEF:_normalize_key:Function]
|
||||
# #endregion SemanticSourceResolver
|
||||
|
||||
# #endregion SemanticSourceResolver
|
||||
@@ -1,6 +1,6 @@
|
||||
# #region GitServiceModule [C:3] [TYPE Module] [SEMANTICS git, service, decomposition, mixin, composition]
|
||||
# @LAYER: Infra
|
||||
# @BRIEF Composed GitService via multiple inheritance from domain-specific mixins.
|
||||
# @LAYER Infra
|
||||
# @RELATION DEPENDS_ON -> [GitServiceBase]
|
||||
# @RELATION DEPENDS_ON -> [GitServiceBranchMixin]
|
||||
# @RELATION DEPENDS_ON -> [GitServiceSyncMixin]
|
||||
@@ -10,8 +10,8 @@
|
||||
# @RELATION DEPENDS_ON -> [GitServiceGiteaMixin]
|
||||
# @RELATION DEPENDS_ON -> [GitServiceGithubMixin]
|
||||
# @RELATION DEPENDS_ON -> [GitServiceGitlabMixin]
|
||||
# @RATIONALE Decomposed from monolithic git_service.py (2101 lines) into
|
||||
#
|
||||
# @RATIONALE: Decomposed from monolithic git_service.py (2101 lines) into
|
||||
# domain-scoped mixins to satisfy INV_7 (module < 400 lines). The composed class
|
||||
# preserves the original public API surface — all consumers continue to import
|
||||
# `from src.services.git_service import GitService` without changes.
|
||||
@@ -32,15 +32,15 @@ __all__ = ["GitService"]
|
||||
# #region GitService [C:3] [TYPE Class]
|
||||
# @BRIEF Composed GitService class providing all Git operations — init, clone, branches, commits, push, pull,
|
||||
# merge, status, diff, history, and provider API operations (Gitea, GitHub, GitLab).
|
||||
# @RELATION: INHERITS -> [GitServiceBase]
|
||||
# @RELATION: INHERITS -> [GitServiceBranchMixin]
|
||||
# @RELATION: INHERITS -> [GitServiceSyncMixin]
|
||||
# @RELATION: INHERITS -> [GitServiceStatusMixin]
|
||||
# @RELATION: INHERITS -> [GitServiceMergeMixin]
|
||||
# @RELATION: INHERITS -> [GitServiceUrlMixin]
|
||||
# @RELATION: INHERITS -> [GitServiceGiteaMixin]
|
||||
# @RELATION: INHERITS -> [GitServiceGithubMixin]
|
||||
# @RELATION: INHERITS -> [GitServiceGitlabMixin]
|
||||
# @RELATION INHERITS -> [GitServiceBase]
|
||||
# @RELATION INHERITS -> [GitServiceBranchMixin]
|
||||
# @RELATION INHERITS -> [GitServiceSyncMixin]
|
||||
# @RELATION INHERITS -> [GitServiceStatusMixin]
|
||||
# @RELATION INHERITS -> [GitServiceMergeMixin]
|
||||
# @RELATION INHERITS -> [GitServiceUrlMixin]
|
||||
# @RELATION INHERITS -> [GitServiceGiteaMixin]
|
||||
# @RELATION INHERITS -> [GitServiceGithubMixin]
|
||||
# @RELATION INHERITS -> [GitServiceGitlabMixin]
|
||||
class GitService(
|
||||
GitServiceGiteaMixin,
|
||||
GitServiceGithubMixin,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# #region GitServiceBase [C:3] [TYPE Module] [SEMANTICS git, service, repository, version_control, initialization]
|
||||
# @LAYER: Infra
|
||||
# @BRIEF Core GitService base class — initialization, path resolution, repo lifecycle (init/delete/get), and identity configuration.
|
||||
# @LAYER Infra
|
||||
# @RELATION INHERITED_BY -> [GitService]
|
||||
# @RELATION DEPENDS_ON -> [SessionLocal]
|
||||
# @RELATION DEPENDS_ON -> [AppConfigRecord]
|
||||
@@ -14,10 +14,7 @@ from typing import Any, Dict, List, Optional
|
||||
from git import Repo
|
||||
from git.exc import InvalidGitRepositoryError, NoSuchPathError
|
||||
from fastapi import HTTPException
|
||||
from src.core.logger import belief_scope
|
||||
from src.core.cot_logger import MarkerLogger
|
||||
|
||||
log = MarkerLogger("GitBase")
|
||||
from src.core.logger import logger, belief_scope
|
||||
from src.models.git import GitRepository
|
||||
from src.models.config import AppConfigRecord
|
||||
from src.core.database import SessionLocal
|
||||
@@ -26,8 +23,8 @@ from src.core.database import SessionLocal
|
||||
# #region GitServiceBase [C:3] [TYPE Class]
|
||||
# @BRIEF Base class for GitService providing initialization, path resolution, repository lifecycle, and identity.
|
||||
class GitServiceBase:
|
||||
# #region GitService_init [TYPE Function]
|
||||
# @BRIEF Initializes the GitService with a base path for repositories.
|
||||
# [DEF:GitService_init:Function]
|
||||
# @PURPOSE: Initializes the GitService with a base path for repositories.
|
||||
# @PARAM: base_path (str) - Root directory for all Git clones.
|
||||
# @PRE: base_path is a valid string path.
|
||||
# @POST: GitService is initialized; base_path directory exists.
|
||||
@@ -38,12 +35,12 @@ class GitServiceBase:
|
||||
self._uses_default_base_path = base_path == "git_repos"
|
||||
self.base_path = self._resolve_base_path(base_path)
|
||||
self._ensure_base_path_exists()
|
||||
# #endregion GitService_init
|
||||
# [/DEF:GitService_init:Function]
|
||||
|
||||
# #region _ensure_base_path_exists [TYPE Function]
|
||||
# @BRIEF Ensure the repositories root directory exists and is a directory.
|
||||
# @PRE self.base_path is resolved to filesystem path.
|
||||
# @POST self.base_path exists as directory or raises ValueError.
|
||||
# [DEF:_ensure_base_path_exists:Function]
|
||||
# @PURPOSE: Ensure the repositories root directory exists and is a directory.
|
||||
# @PRE: self.base_path is resolved to filesystem path.
|
||||
# @POST: self.base_path exists as directory or raises ValueError.
|
||||
def _ensure_base_path_exists(self) -> None:
|
||||
base = Path(self.base_path)
|
||||
if base.exists() and not base.is_dir():
|
||||
@@ -51,15 +48,17 @@ class GitServiceBase:
|
||||
try:
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
except (PermissionError, OSError) as e:
|
||||
log.explore("Cannot create Git repositories base path", payload={"base_path": self.base_path}, error=str(e))
|
||||
logger.warning(
|
||||
f"[_ensure_base_path_exists][Coherence:Failed] Cannot create Git repositories base path: {self.base_path}. Error: {e}"
|
||||
)
|
||||
raise ValueError(f"Cannot create Git repositories base path: {self.base_path}. {e}")
|
||||
# #endregion _ensure_base_path_exists
|
||||
# [/DEF:_ensure_base_path_exists:Function]
|
||||
|
||||
# #region _resolve_base_path [TYPE Function]
|
||||
# @BRIEF Resolve base repository directory from explicit argument or global storage settings.
|
||||
# @PRE base_path is a string path.
|
||||
# @POST Returns absolute path for Git repositories root.
|
||||
# @RETURN str
|
||||
# [DEF:_resolve_base_path:Function]
|
||||
# @PURPOSE: Resolve base repository directory from explicit argument or global storage settings.
|
||||
# @PRE: base_path is a string path.
|
||||
# @POST: Returns absolute path for Git repositories root.
|
||||
# @RETURN: str
|
||||
def _resolve_base_path(self, base_path: str) -> str:
|
||||
backend_root = Path(__file__).parents[3]
|
||||
fallback_path = str((backend_root / base_path).resolve())
|
||||
@@ -86,25 +85,25 @@ class GitServiceBase:
|
||||
return str(repo_root.resolve())
|
||||
return str((root / repo_root).resolve())
|
||||
except Exception as e:
|
||||
log.explore("Falling back to default base path", error=str(e))
|
||||
logger.warning(f"[_resolve_base_path][Coherence:Failed] Falling back to default path: {e}")
|
||||
return fallback_path
|
||||
# #endregion _resolve_base_path
|
||||
# [/DEF:_resolve_base_path:Function]
|
||||
|
||||
# #region _normalize_repo_key [TYPE Function]
|
||||
# @BRIEF Convert user/dashboard-provided key to safe filesystem directory name.
|
||||
# @PRE repo_key can be None/empty.
|
||||
# @POST Returns normalized non-empty key.
|
||||
# @RETURN str
|
||||
# [DEF:_normalize_repo_key:Function]
|
||||
# @PURPOSE: Convert user/dashboard-provided key to safe filesystem directory name.
|
||||
# @PRE: repo_key can be None/empty.
|
||||
# @POST: Returns normalized non-empty key.
|
||||
# @RETURN: str
|
||||
def _normalize_repo_key(self, repo_key: Optional[str]) -> str:
|
||||
raw_key = str(repo_key or "").strip().lower()
|
||||
normalized = re.sub(r"[^a-z0-9._-]+", "-", raw_key).strip("._-")
|
||||
return normalized or "dashboard"
|
||||
# #endregion _normalize_repo_key
|
||||
# [/DEF:_normalize_repo_key:Function]
|
||||
|
||||
# #region _update_repo_local_path [TYPE Function]
|
||||
# @BRIEF Persist repository local_path in GitRepository table when record exists.
|
||||
# @PRE dashboard_id is valid integer.
|
||||
# @POST local_path is updated for existing record.
|
||||
# [DEF:_update_repo_local_path:Function]
|
||||
# @PURPOSE: Persist repository local_path in GitRepository table when record exists.
|
||||
# @PRE: dashboard_id is valid integer.
|
||||
# @POST: local_path is updated for existing record.
|
||||
def _update_repo_local_path(self, dashboard_id: int, local_path: str) -> None:
|
||||
try:
|
||||
session = SessionLocal()
|
||||
@@ -120,21 +119,21 @@ class GitServiceBase:
|
||||
finally:
|
||||
session.close()
|
||||
except Exception as e:
|
||||
log.explore("Failed to update repo local path", error=str(e))
|
||||
# #endregion _update_repo_local_path
|
||||
logger.warning(f"[_update_repo_local_path][Coherence:Failed] {e}")
|
||||
# [/DEF:_update_repo_local_path:Function]
|
||||
|
||||
# #region _migrate_repo_directory [TYPE Function]
|
||||
# @BRIEF Move legacy repository directory to target path and sync DB metadata.
|
||||
# @PRE source_path exists.
|
||||
# @POST Repository content available at target_path.
|
||||
# @RETURN str
|
||||
# [DEF:_migrate_repo_directory:Function]
|
||||
# @PURPOSE: Move legacy repository directory to target path and sync DB metadata.
|
||||
# @PRE: source_path exists.
|
||||
# @POST: Repository content available at target_path.
|
||||
# @RETURN: str
|
||||
def _migrate_repo_directory(self, dashboard_id: int, source_path: str, target_path: str) -> str:
|
||||
source_abs = os.path.abspath(source_path)
|
||||
target_abs = os.path.abspath(target_path)
|
||||
if source_abs == target_abs:
|
||||
return source_abs
|
||||
if os.path.exists(target_abs):
|
||||
log.explore(f"Target already exists, keeping source path: {target_abs}", error="Target path exists")
|
||||
logger.warning(f"[_migrate_repo_directory][Action] Target already exists, keeping source path: {target_abs}")
|
||||
return source_abs
|
||||
Path(target_abs).parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
@@ -142,12 +141,14 @@ class GitServiceBase:
|
||||
except OSError:
|
||||
shutil.move(source_abs, target_abs)
|
||||
self._update_repo_local_path(dashboard_id, target_abs)
|
||||
log.reflect(f"Repository migrated for dashboard {dashboard_id}: {source_abs} -> {target_abs}")
|
||||
logger.info(
|
||||
f"[_migrate_repo_directory][Coherence:OK] Repository migrated for dashboard {dashboard_id}: {source_abs} -> {target_abs}"
|
||||
)
|
||||
return target_abs
|
||||
# #endregion _migrate_repo_directory
|
||||
# [/DEF:_migrate_repo_directory:Function]
|
||||
|
||||
# #region _get_repo_path [TYPE Function]
|
||||
# @BRIEF Resolves the local filesystem path for a dashboard's repository.
|
||||
# [DEF:_get_repo_path:Function]
|
||||
# @PURPOSE: Resolves the local filesystem path for a dashboard's repository.
|
||||
# @PARAM: dashboard_id (int)
|
||||
# @PARAM: repo_key (Optional[str]) - Slug-like key used when DB local_path is absent.
|
||||
# @PRE: dashboard_id is an integer.
|
||||
@@ -183,17 +184,17 @@ class GitServiceBase:
|
||||
return self._migrate_repo_directory(dashboard_id, db_path, target_path)
|
||||
return db_path
|
||||
except Exception as e:
|
||||
log.explore("Could not resolve local_path from DB", error=str(e))
|
||||
logger.warning(f"[_get_repo_path][Coherence:Failed] Could not resolve local_path from DB: {e}")
|
||||
legacy_id_path = os.path.join(self.legacy_base_path, str(dashboard_id))
|
||||
if os.path.exists(legacy_id_path) and not os.path.exists(target_path):
|
||||
return self._migrate_repo_directory(dashboard_id, legacy_id_path, target_path)
|
||||
if os.path.exists(target_path):
|
||||
self._update_repo_local_path(dashboard_id, target_path)
|
||||
return target_path
|
||||
# #endregion _get_repo_path
|
||||
# [/DEF:_get_repo_path:Function]
|
||||
|
||||
# #region init_repo [TYPE Function]
|
||||
# @BRIEF Initialize or clone a repository for a dashboard.
|
||||
# [DEF:init_repo:Function]
|
||||
# @PURPOSE: Initialize or clone a repository for a dashboard.
|
||||
# @PARAM: dashboard_id (int), remote_url (str), pat (str), repo_key (Optional[str]).
|
||||
# @PRE: dashboard_id is int, remote_url is valid Git URL, pat is provided.
|
||||
# @POST: Repository is cloned or opened at the local path.
|
||||
@@ -209,11 +210,11 @@ class GitServiceBase:
|
||||
else:
|
||||
auth_url = remote_url
|
||||
if os.path.exists(repo_path):
|
||||
log.reason(f"Opening existing repo at {repo_path}")
|
||||
logger.info(f"[init_repo][Action] Opening existing repo at {repo_path}")
|
||||
try:
|
||||
repo = Repo(repo_path)
|
||||
except (InvalidGitRepositoryError, NoSuchPathError):
|
||||
log.explore(f"Existing path is not a Git repository, recreating: {repo_path}", error="Not a git repository")
|
||||
logger.warning(f"[init_repo][Action] Existing path is not a Git repository, recreating: {repo_path}")
|
||||
stale_path = Path(repo_path)
|
||||
if stale_path.exists():
|
||||
shutil.rmtree(stale_path, ignore_errors=True)
|
||||
@@ -225,16 +226,16 @@ class GitServiceBase:
|
||||
repo = Repo.clone_from(auth_url, repo_path)
|
||||
self._ensure_gitflow_branches(repo, dashboard_id)
|
||||
return repo
|
||||
log.reason(f"Cloning {remote_url} to {repo_path}")
|
||||
logger.info(f"[init_repo][Action] Cloning {remote_url} to {repo_path}")
|
||||
repo = Repo.clone_from(auth_url, repo_path)
|
||||
self._ensure_gitflow_branches(repo, dashboard_id)
|
||||
return repo
|
||||
# #endregion init_repo
|
||||
# [/DEF:init_repo:Function]
|
||||
|
||||
# #region delete_repo [TYPE Function]
|
||||
# @BRIEF Remove local repository and DB binding for a dashboard.
|
||||
# @PRE dashboard_id is a valid integer.
|
||||
# @POST Local path is deleted when present and GitRepository row is removed.
|
||||
# [DEF:delete_repo:Function]
|
||||
# @PURPOSE: Remove local repository and DB binding for a dashboard.
|
||||
# @PRE: dashboard_id is a valid integer.
|
||||
# @POST: Local path is deleted when present and GitRepository row is removed.
|
||||
def delete_repo(self, dashboard_id: int) -> None:
|
||||
with belief_scope("GitService.delete_repo"):
|
||||
repo_path = self._get_repo_path(dashboard_id)
|
||||
@@ -267,34 +268,34 @@ class GitServiceBase:
|
||||
raise
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
log.explore(f"Failed to delete repository for dashboard {dashboard_id}", error=str(e))
|
||||
logger.error(f"[delete_repo][Coherence:Failed] Failed to delete repository for dashboard {dashboard_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete repository: {str(e)}")
|
||||
finally:
|
||||
session.close()
|
||||
# #endregion delete_repo
|
||||
# [/DEF:delete_repo:Function]
|
||||
|
||||
# #region get_repo [TYPE Function]
|
||||
# @BRIEF Get Repo object for a dashboard.
|
||||
# @PRE Repository must exist on disk for the given dashboard_id.
|
||||
# @POST Returns a GitPython Repo instance for the dashboard.
|
||||
# @RETURN Repo
|
||||
# [DEF:get_repo:Function]
|
||||
# @PURPOSE: Get Repo object for a dashboard.
|
||||
# @PRE: Repository must exist on disk for the given dashboard_id.
|
||||
# @POST: Returns a GitPython Repo instance for the dashboard.
|
||||
# @RETURN: Repo
|
||||
def get_repo(self, dashboard_id: int) -> Repo:
|
||||
with belief_scope("GitService.get_repo"):
|
||||
repo_path = self._get_repo_path(dashboard_id)
|
||||
if not os.path.exists(repo_path):
|
||||
log.explore(f"Repository for dashboard {dashboard_id} does not exist", error="Repository not found")
|
||||
logger.error(f"[get_repo][Coherence:Failed] Repository for dashboard {dashboard_id} does not exist")
|
||||
raise HTTPException(status_code=404, detail=f"Repository for dashboard {dashboard_id} not found")
|
||||
try:
|
||||
return Repo(repo_path)
|
||||
except Exception as e:
|
||||
log.explore(f"Failed to open repository at {repo_path}", error=str(e))
|
||||
logger.error(f"[get_repo][Coherence:Failed] Failed to open repository at {repo_path}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to open local Git repository")
|
||||
# #endregion get_repo
|
||||
# [/DEF:get_repo:Function]
|
||||
|
||||
# #region configure_identity [TYPE Function]
|
||||
# @BRIEF Configure repository-local Git committer identity for user-scoped operations.
|
||||
# @PRE dashboard_id repository exists; git_username/git_email may be empty.
|
||||
# @POST Repository config has user.name and user.email when both identity values are provided.
|
||||
# [DEF:configure_identity:Function]
|
||||
# @PURPOSE: Configure repository-local Git committer identity for user-scoped operations.
|
||||
# @PRE: dashboard_id repository exists; git_username/git_email may be empty.
|
||||
# @POST: Repository config has user.name and user.email when both identity values are provided.
|
||||
def configure_identity(self, dashboard_id: int, git_username: Optional[str], git_email: Optional[str]) -> None:
|
||||
with belief_scope("GitService.configure_identity"):
|
||||
normalized_username = str(git_username or "").strip()
|
||||
@@ -306,10 +307,10 @@ class GitServiceBase:
|
||||
with repo.config_writer(config_level="repository") as config_writer:
|
||||
config_writer.set_value("user", "name", normalized_username)
|
||||
config_writer.set_value("user", "email", normalized_email)
|
||||
log.reason(f"Applied repository-local git identity for dashboard {dashboard_id}")
|
||||
logger.info("[configure_identity][Action] Applied repository-local git identity for dashboard %s", dashboard_id)
|
||||
except Exception as e:
|
||||
log.explore("Failed to configure git identity", error=str(e))
|
||||
logger.error(f"[configure_identity][Coherence:Failed] Failed to configure git identity: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to configure git identity: {str(e)}")
|
||||
# #endregion configure_identity
|
||||
# [/DEF:configure_identity:Function]
|
||||
# #endregion GitServiceBase
|
||||
# #endregion GitServiceBase
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# #region GitServiceBranchMixin [C:3] [TYPE Module] [SEMANTICS git, branches, commits, checkout, gitflow]
|
||||
# @LAYER: Infra
|
||||
# @BRIEF Branch and commit operations for GitService — gitflow branches, list/create/checkout branches, commit changes.
|
||||
# @LAYER Infra
|
||||
# @RELATION USED_BY -> [GitService]
|
||||
|
||||
import os
|
||||
@@ -9,19 +9,16 @@ from datetime import datetime
|
||||
from git import Repo
|
||||
from git.exc import GitCommandError
|
||||
from fastapi import HTTPException
|
||||
from src.core.logger import belief_scope
|
||||
from src.core.cot_logger import MarkerLogger
|
||||
|
||||
log = MarkerLogger("GitBranch")
|
||||
from src.core.logger import logger, belief_scope
|
||||
|
||||
|
||||
# #region GitServiceBranchMixin [C:3] [TYPE Class]
|
||||
# @BRIEF Mixin providing branch and commit operations for GitService.
|
||||
class GitServiceBranchMixin:
|
||||
# #region _ensure_gitflow_branches [TYPE Function]
|
||||
# @BRIEF Ensure standard GitFlow branches (main/dev/preprod) exist locally and on origin.
|
||||
# @PRE repo is a valid GitPython Repo instance.
|
||||
# @POST main, dev, preprod are available in local repository and pushed to origin when available.
|
||||
# [DEF:_ensure_gitflow_branches:Function]
|
||||
# @PURPOSE: Ensure standard GitFlow branches (main/dev/preprod) exist locally and on origin.
|
||||
# @PRE: repo is a valid GitPython Repo instance.
|
||||
# @POST: main, dev, preprod are available in local repository and pushed to origin when available.
|
||||
def _ensure_gitflow_branches(self, repo: Repo, dashboard_id: int) -> None:
|
||||
with belief_scope("GitService._ensure_gitflow_branches"):
|
||||
required_branches = ["main", "dev", "preprod"]
|
||||
@@ -34,20 +31,26 @@ class GitServiceBranchMixin:
|
||||
if "main" in local_heads:
|
||||
base_commit = local_heads["main"].commit
|
||||
if base_commit is None:
|
||||
log.explore("Branch bootstrap skipped - no commits", payload={"dashboard_id": dashboard_id}, error="Repository has no initial commit to branch from")
|
||||
logger.warning(
|
||||
f"[_ensure_gitflow_branches][Action] Skipping branch bootstrap for dashboard {dashboard_id}: repository has no commits"
|
||||
)
|
||||
return
|
||||
if "main" not in local_heads:
|
||||
local_heads["main"] = repo.create_head("main", base_commit)
|
||||
log.reason("Created local branch main", payload={"dashboard_id": dashboard_id})
|
||||
logger.info(f"[_ensure_gitflow_branches][Action] Created local branch main for dashboard {dashboard_id}")
|
||||
for branch_name in ("dev", "preprod"):
|
||||
if branch_name in local_heads:
|
||||
continue
|
||||
local_heads[branch_name] = repo.create_head(branch_name, local_heads["main"].commit)
|
||||
log.reason("Created local branch", payload={"branch_name": branch_name, "dashboard_id": dashboard_id})
|
||||
logger.info(
|
||||
f"[_ensure_gitflow_branches][Action] Created local branch {branch_name} for dashboard {dashboard_id}"
|
||||
)
|
||||
try:
|
||||
origin = repo.remote(name="origin")
|
||||
except ValueError:
|
||||
log.reason("Remote origin not configured; skipping remote branch creation", payload={"dashboard_id": dashboard_id})
|
||||
logger.info(
|
||||
f"[_ensure_gitflow_branches][Action] Remote origin is not configured for dashboard {dashboard_id}; skipping remote branch creation"
|
||||
)
|
||||
return
|
||||
remote_branch_names = set()
|
||||
try:
|
||||
@@ -57,35 +60,37 @@ class GitServiceBranchMixin:
|
||||
if remote_head:
|
||||
remote_branch_names.add(str(remote_head))
|
||||
except Exception as e:
|
||||
log.explore("Failed to fetch origin refs", error=str(e))
|
||||
logger.warning(f"[_ensure_gitflow_branches][Action] Failed to fetch origin refs: {e}")
|
||||
for branch_name in required_branches:
|
||||
if branch_name in remote_branch_names:
|
||||
continue
|
||||
try:
|
||||
origin.push(refspec=f"{branch_name}:{branch_name}")
|
||||
log.reason("Pushed branch to origin", payload={"branch_name": branch_name, "dashboard_id": dashboard_id})
|
||||
logger.info(
|
||||
f"[_ensure_gitflow_branches][Action] Pushed branch {branch_name} to origin for dashboard {dashboard_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
log.explore("Failed to push branch to origin", error=str(e))
|
||||
logger.error(f"[_ensure_gitflow_branches][Coherence:Failed] Failed to push branch {branch_name} to origin: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to create default branch '{branch_name}' on remote: {str(e)}",
|
||||
)
|
||||
try:
|
||||
repo.git.checkout("dev")
|
||||
log.reason("Checked out default branch dev", payload={"dashboard_id": dashboard_id})
|
||||
logger.info(f"[_ensure_gitflow_branches][Action] Checked out default branch dev for dashboard {dashboard_id}")
|
||||
except Exception as e:
|
||||
log.explore("Could not checkout dev branch", payload={"dashboard_id": dashboard_id}, error=str(e))
|
||||
# #endregion _ensure_gitflow_branches
|
||||
logger.warning(f"[_ensure_gitflow_branches][Action] Could not checkout dev branch for dashboard {dashboard_id}: {e}")
|
||||
# [/DEF:_ensure_gitflow_branches:Function]
|
||||
|
||||
# #region list_branches [TYPE Function]
|
||||
# @BRIEF List all branches for a dashboard's repository.
|
||||
# @PRE Repository for dashboard_id exists.
|
||||
# @POST Returns a list of branch metadata dictionaries.
|
||||
# @RETURN List[dict]
|
||||
# [DEF:list_branches:Function]
|
||||
# @PURPOSE: List all branches for a dashboard's repository.
|
||||
# @PRE: Repository for dashboard_id exists.
|
||||
# @POST: Returns a list of branch metadata dictionaries.
|
||||
# @RETURN: List[dict]
|
||||
def list_branches(self, dashboard_id: int) -> List[dict]:
|
||||
with belief_scope("GitService.list_branches"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
log.reason("Listing branches", payload={"dashboard_id": dashboard_id})
|
||||
logger.info(f"[list_branches][Action] Listing branches for {dashboard_id}. Refs: {repo.refs}")
|
||||
branches = []
|
||||
for ref in repo.refs:
|
||||
try:
|
||||
@@ -99,7 +104,7 @@ class GitServiceBranchMixin:
|
||||
"last_updated": datetime.fromtimestamp(ref.commit.committed_date) if hasattr(ref, 'commit') else datetime.utcnow()
|
||||
})
|
||||
except Exception as e:
|
||||
log.explore("Skipping ref", payload={"ref": str(ref)}, error=str(e))
|
||||
logger.warning(f"[list_branches][Action] Skipping ref {ref}: {e}")
|
||||
try:
|
||||
active_name = repo.active_branch.name
|
||||
if not any(b['name'] == active_name for b in branches):
|
||||
@@ -108,17 +113,17 @@ class GitServiceBranchMixin:
|
||||
"is_remote": False, "last_updated": datetime.utcnow()
|
||||
})
|
||||
except Exception as e:
|
||||
log.explore("Could not determine active branch", error=str(e))
|
||||
logger.warning(f"[list_branches][Action] Could not determine active branch: {e}")
|
||||
if not branches:
|
||||
branches.append({
|
||||
"name": "dev", "commit_hash": "0000000",
|
||||
"is_remote": False, "last_updated": datetime.utcnow()
|
||||
})
|
||||
return branches
|
||||
# #endregion list_branches
|
||||
# [/DEF:list_branches:Function]
|
||||
|
||||
# #region create_branch [TYPE Function]
|
||||
# @BRIEF Create a new branch from an existing one.
|
||||
# [DEF:create_branch:Function]
|
||||
# @PURPOSE: Create a new branch from an existing one.
|
||||
# @PARAM: name (str) - New branch name.
|
||||
# @PARAM: from_branch (str) - Source branch.
|
||||
# @PRE: Repository exists; name is valid; from_branch exists or repo is empty.
|
||||
@@ -126,9 +131,9 @@ class GitServiceBranchMixin:
|
||||
def create_branch(self, dashboard_id: int, name: str, from_branch: str = "main"):
|
||||
with belief_scope("GitService.create_branch"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
log.reason("Creating branch", payload={"name": name, "from_branch": from_branch})
|
||||
logger.info(f"[create_branch][Action] Creating branch {name} from {from_branch}")
|
||||
if not repo.heads and not repo.remotes:
|
||||
log.explore("Repository is empty; creating initial commit to enable branching", error="No branches or remotes exist; initializing from scratch")
|
||||
logger.warning("[create_branch][Action] Repository is empty. Creating initial commit to enable branching.")
|
||||
readme_path = os.path.join(repo.working_dir, "README.md")
|
||||
if not os.path.exists(readme_path):
|
||||
with open(readme_path, "w") as f:
|
||||
@@ -138,29 +143,29 @@ class GitServiceBranchMixin:
|
||||
try:
|
||||
repo.commit(from_branch)
|
||||
except Exception:
|
||||
log.explore("Source branch not found, using HEAD", payload={"from_branch": from_branch}, error=f"Branch '{from_branch}' does not exist, falling back to HEAD")
|
||||
logger.warning(f"[create_branch][Action] Source branch {from_branch} not found, using HEAD")
|
||||
from_branch = repo.head
|
||||
try:
|
||||
new_branch = repo.create_head(name, from_branch)
|
||||
return new_branch
|
||||
except Exception as e:
|
||||
log.explore("Failed to create branch", error=str(e))
|
||||
logger.error(f"[create_branch][Coherence:Failed] {e}")
|
||||
raise
|
||||
# #endregion create_branch
|
||||
# [/DEF:create_branch:Function]
|
||||
|
||||
# #region checkout_branch [TYPE Function]
|
||||
# @BRIEF Switch to a specific branch.
|
||||
# @PRE Repository exists and the specified branch name exists.
|
||||
# @POST The repository working directory is updated to the specified branch.
|
||||
# [DEF:checkout_branch:Function]
|
||||
# @PURPOSE: Switch to a specific branch.
|
||||
# @PRE: Repository exists and the specified branch name exists.
|
||||
# @POST: The repository working directory is updated to the specified branch.
|
||||
def checkout_branch(self, dashboard_id: int, name: str):
|
||||
with belief_scope("GitService.checkout_branch"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
log.reason("Checking out branch", payload={"name": name})
|
||||
logger.info(f"[checkout_branch][Action] Checking out branch {name}")
|
||||
repo.git.checkout(name)
|
||||
# #endregion checkout_branch
|
||||
# [/DEF:checkout_branch:Function]
|
||||
|
||||
# #region commit_changes [TYPE Function]
|
||||
# @BRIEF Stage and commit changes.
|
||||
# [DEF:commit_changes:Function]
|
||||
# @PURPOSE: Stage and commit changes.
|
||||
# @PARAM: message (str) - Commit message.
|
||||
# @PARAM: files (List[str]) - Optional list of specific files to stage.
|
||||
# @PRE: Repository exists and has changes (dirty) or files are specified.
|
||||
@@ -169,16 +174,16 @@ class GitServiceBranchMixin:
|
||||
with belief_scope("GitService.commit_changes"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
if not repo.is_dirty(untracked_files=True) and not files:
|
||||
log.reason("No changes to commit", payload={"dashboard_id": dashboard_id})
|
||||
logger.info(f"[commit_changes][Action] No changes to commit for dashboard {dashboard_id}")
|
||||
return
|
||||
if files:
|
||||
log.reason("Staging files", payload={"files": files})
|
||||
logger.info(f"[commit_changes][Action] Staging files: {files}")
|
||||
repo.index.add(files)
|
||||
else:
|
||||
log.reason("Staging all changes")
|
||||
logger.info("[commit_changes][Action] Staging all changes")
|
||||
repo.git.add(A=True)
|
||||
repo.index.commit(message)
|
||||
log.reflect("Committed changes", payload={"message": message})
|
||||
# #endregion commit_changes
|
||||
logger.info(f"[commit_changes][Coherence:OK] Committed changes with message: {message}")
|
||||
# [/DEF:commit_changes:Function]
|
||||
# #endregion GitServiceBranchMixin
|
||||
# #endregion GitServiceBranchMixin
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
# #region GitServiceGiteaMixin [C:3] [TYPE Module] [SEMANTICS git, gitea, api, provider, connection_test]
|
||||
# @LAYER: Infra
|
||||
# @BRIEF Gitea API operations for GitService — connection testing, repository CRUD, and pull request creation.
|
||||
# @LAYER Infra
|
||||
# @RELATION USED_BY -> [GitService]
|
||||
|
||||
import httpx
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import quote
|
||||
from fastapi import HTTPException
|
||||
from src.core.cot_logger import MarkerLogger
|
||||
from src.core.logger import belief_scope
|
||||
|
||||
log = MarkerLogger("GitGitea")
|
||||
from src.core.logger import logger, belief_scope
|
||||
from src.models.git import GitProvider
|
||||
|
||||
|
||||
# #region GitServiceGiteaMixin [C:3] [TYPE Class]
|
||||
# @BRIEF Mixin providing Gitea API operations for GitService.
|
||||
class GitServiceGiteaMixin:
|
||||
# #region test_connection [TYPE Function]
|
||||
# @BRIEF Test connection to Git provider using PAT.
|
||||
# [DEF:test_connection:Function]
|
||||
# @PURPOSE: Test connection to Git provider using PAT.
|
||||
# @PARAM: provider (GitProvider), url (str), pat (str)
|
||||
# @PRE: provider is valid; url is a valid HTTP(S) URL; pat is provided.
|
||||
# @POST: Returns True if connection to the provider's API succeeds.
|
||||
@@ -26,13 +23,13 @@ class GitServiceGiteaMixin:
|
||||
async def test_connection(self, provider: GitProvider, url: str, pat: str) -> bool:
|
||||
with belief_scope("GitService.test_connection"):
|
||||
if ".local" in url or "localhost" in url:
|
||||
log.reason("Local/Offline mode detected for URL")
|
||||
logger.info("[test_connection][Action] Local/Offline mode detected for URL")
|
||||
return True
|
||||
if not url.startswith(('http://', 'https://')):
|
||||
log.explore(f"Invalid URL protocol: {url}", error="Invalid URL protocol")
|
||||
logger.error(f"[test_connection][Coherence:Failed] Invalid URL protocol: {url}")
|
||||
return False
|
||||
if not pat or not pat.strip():
|
||||
log.explore("Git PAT is missing or empty", error="Git PAT is missing or empty")
|
||||
logger.error("[test_connection][Coherence:Failed] Git PAT is missing or empty")
|
||||
return False
|
||||
pat = pat.strip()
|
||||
try:
|
||||
@@ -52,18 +49,18 @@ class GitServiceGiteaMixin:
|
||||
else:
|
||||
return False
|
||||
if resp.status_code != 200:
|
||||
log.explore(f"Git connection test failed for {provider} at {api_url}. Status: {resp.status_code}", error="Git connection test failed")
|
||||
logger.error(f"[test_connection][Coherence:Failed] Git connection test failed for {provider} at {api_url}. Status: {resp.status_code}")
|
||||
return resp.status_code == 200
|
||||
except Exception as e:
|
||||
log.explore(f"Error testing git connection: {e}", error=str(e))
|
||||
logger.error(f"[test_connection][Coherence:Failed] Error testing git connection: {e}")
|
||||
return False
|
||||
# #endregion test_connection
|
||||
# [/DEF:test_connection:Function]
|
||||
|
||||
# #region _gitea_headers [TYPE Function]
|
||||
# @BRIEF Build Gitea API authorization headers.
|
||||
# @PRE pat is provided.
|
||||
# @POST Returns headers with token auth.
|
||||
# @RETURN Dict[str, str]
|
||||
# [DEF:_gitea_headers:Function]
|
||||
# @PURPOSE: Build Gitea API authorization headers.
|
||||
# @PRE: pat is provided.
|
||||
# @POST: Returns headers with token auth.
|
||||
# @RETURN: Dict[str, str]
|
||||
def _gitea_headers(self, pat: str) -> Dict[str, str]:
|
||||
token = (pat or "").strip()
|
||||
if not token:
|
||||
@@ -73,13 +70,13 @@ class GitServiceGiteaMixin:
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
# #endregion _gitea_headers
|
||||
# [/DEF:_gitea_headers:Function]
|
||||
|
||||
# #region _gitea_request [TYPE Function]
|
||||
# @BRIEF Execute HTTP request against Gitea API with stable error mapping.
|
||||
# @PRE method and endpoint are valid.
|
||||
# @POST Returns decoded JSON payload.
|
||||
# @RETURN Any
|
||||
# [DEF:_gitea_request:Function]
|
||||
# @PURPOSE: Execute HTTP request against Gitea API with stable error mapping.
|
||||
# @PRE: method and endpoint are valid.
|
||||
# @POST: Returns decoded JSON payload.
|
||||
# @RETURN: Any
|
||||
async def _gitea_request(
|
||||
self, method: str, server_url: str, pat: str, endpoint: str,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
@@ -91,7 +88,7 @@ class GitServiceGiteaMixin:
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
response = await client.request(method=method, url=url, headers=headers, json=payload)
|
||||
except Exception as e:
|
||||
log.explore(f"Network error: {e}", error=str(e))
|
||||
logger.error(f"[gitea_request][Coherence:Failed] Network error: {e}")
|
||||
raise HTTPException(status_code=503, detail=f"Gitea API is unavailable: {str(e)}")
|
||||
if response.status_code >= 400:
|
||||
detail = response.text
|
||||
@@ -100,43 +97,43 @@ class GitServiceGiteaMixin:
|
||||
detail = parsed.get("message") or parsed.get("error") or detail
|
||||
except Exception:
|
||||
pass
|
||||
log.explore(f"Gitea API error ({endpoint})", payload={"method": method, "status": response.status_code}, error=detail)
|
||||
logger.error(f"[gitea_request][Coherence:Failed] method={method} endpoint={endpoint} status={response.status_code} detail={detail}")
|
||||
raise HTTPException(status_code=response.status_code, detail=f"Gitea API error: {detail}")
|
||||
if response.status_code == 204:
|
||||
return None
|
||||
return response.json()
|
||||
# #endregion _gitea_request
|
||||
# [/DEF:_gitea_request:Function]
|
||||
|
||||
# #region get_gitea_current_user [TYPE Function]
|
||||
# @BRIEF Resolve current Gitea user for PAT.
|
||||
# @PRE server_url and pat are valid.
|
||||
# @POST Returns current username.
|
||||
# @RETURN str
|
||||
# [DEF:get_gitea_current_user:Function]
|
||||
# @PURPOSE: Resolve current Gitea user for PAT.
|
||||
# @PRE: server_url and pat are valid.
|
||||
# @POST: Returns current username.
|
||||
# @RETURN: str
|
||||
async def get_gitea_current_user(self, server_url: str, pat: str) -> str:
|
||||
payload = await self._gitea_request("GET", server_url, pat, "/user")
|
||||
username = payload.get("login") or payload.get("username")
|
||||
if not username:
|
||||
raise HTTPException(status_code=500, detail="Failed to resolve Gitea username")
|
||||
return str(username)
|
||||
# #endregion get_gitea_current_user
|
||||
# [/DEF:get_gitea_current_user:Function]
|
||||
|
||||
# #region list_gitea_repositories [TYPE Function]
|
||||
# @BRIEF List repositories visible to authenticated Gitea user.
|
||||
# @PRE server_url and pat are valid.
|
||||
# @POST Returns repository list from Gitea.
|
||||
# @RETURN List[dict]
|
||||
# [DEF:list_gitea_repositories:Function]
|
||||
# @PURPOSE: List repositories visible to authenticated Gitea user.
|
||||
# @PRE: server_url and pat are valid.
|
||||
# @POST: Returns repository list from Gitea.
|
||||
# @RETURN: List[dict]
|
||||
async def list_gitea_repositories(self, server_url: str, pat: str) -> List[dict]:
|
||||
payload = await self._gitea_request("GET", server_url, pat, "/user/repos?limit=100&page=1")
|
||||
if not isinstance(payload, list):
|
||||
return []
|
||||
return payload
|
||||
# #endregion list_gitea_repositories
|
||||
# [/DEF:list_gitea_repositories:Function]
|
||||
|
||||
# #region create_gitea_repository [TYPE Function]
|
||||
# @BRIEF Create repository in Gitea for authenticated user.
|
||||
# @PRE name is non-empty and PAT has repo creation permission.
|
||||
# @POST Returns created repository payload.
|
||||
# @RETURN dict
|
||||
# [DEF:create_gitea_repository:Function]
|
||||
# @PURPOSE: Create repository in Gitea for authenticated user.
|
||||
# @PRE: name is non-empty and PAT has repo creation permission.
|
||||
# @POST: Returns created repository payload.
|
||||
# @RETURN: dict
|
||||
async def create_gitea_repository(
|
||||
self, server_url: str, pat: str, name: str, private: bool = True,
|
||||
description: Optional[str] = None, auto_init: bool = True, default_branch: Optional[str] = "main",
|
||||
@@ -150,23 +147,23 @@ class GitServiceGiteaMixin:
|
||||
if not isinstance(created, dict):
|
||||
raise HTTPException(status_code=500, detail="Unexpected Gitea response while creating repository")
|
||||
return created
|
||||
# #endregion create_gitea_repository
|
||||
# [/DEF:create_gitea_repository:Function]
|
||||
|
||||
# #region delete_gitea_repository [TYPE Function]
|
||||
# @BRIEF Delete repository in Gitea.
|
||||
# @PRE owner and repo_name are non-empty.
|
||||
# @POST Repository deleted on Gitea server.
|
||||
# [DEF:delete_gitea_repository:Function]
|
||||
# @PURPOSE: Delete repository in Gitea.
|
||||
# @PRE: owner and repo_name are non-empty.
|
||||
# @POST: Repository deleted on Gitea server.
|
||||
async def delete_gitea_repository(self, server_url: str, pat: str, owner: str, repo_name: str) -> None:
|
||||
if not owner or not repo_name:
|
||||
raise HTTPException(status_code=400, detail="owner and repo_name are required")
|
||||
await self._gitea_request("DELETE", server_url, pat, f"/repos/{owner}/{repo_name}")
|
||||
# #endregion delete_gitea_repository
|
||||
# [/DEF:delete_gitea_repository:Function]
|
||||
|
||||
# #region _gitea_branch_exists [TYPE Function]
|
||||
# @BRIEF Check whether a branch exists in Gitea repository.
|
||||
# @PRE owner/repo/branch are non-empty.
|
||||
# @POST Returns True when branch exists, False when 404.
|
||||
# @RETURN bool
|
||||
# [DEF:_gitea_branch_exists:Function]
|
||||
# @PURPOSE: Check whether a branch exists in Gitea repository.
|
||||
# @PRE: owner/repo/branch are non-empty.
|
||||
# @POST: Returns True when branch exists, False when 404.
|
||||
# @RETURN: bool
|
||||
async def _gitea_branch_exists(self, server_url: str, pat: str, owner: str, repo: str, branch: str) -> bool:
|
||||
if not owner or not repo or not branch:
|
||||
return False
|
||||
@@ -178,13 +175,13 @@ class GitServiceGiteaMixin:
|
||||
if exc.status_code == 404:
|
||||
return False
|
||||
raise
|
||||
# #endregion _gitea_branch_exists
|
||||
# [/DEF:_gitea_branch_exists:Function]
|
||||
|
||||
# #region _build_gitea_pr_404_detail [TYPE Function]
|
||||
# @BRIEF Build actionable error detail for Gitea PR 404 responses.
|
||||
# @PRE owner/repo/from_branch/to_branch are provided.
|
||||
# @POST Returns specific branch-missing message when detected.
|
||||
# @RETURN Optional[str]
|
||||
# [DEF:_build_gitea_pr_404_detail:Function]
|
||||
# @PURPOSE: Build actionable error detail for Gitea PR 404 responses.
|
||||
# @PRE: owner/repo/from_branch/to_branch are provided.
|
||||
# @POST: Returns specific branch-missing message when detected.
|
||||
# @RETURN: Optional[str]
|
||||
async def _build_gitea_pr_404_detail(
|
||||
self, server_url: str, pat: str, owner: str, repo: str, from_branch: str, to_branch: str,
|
||||
) -> Optional[str]:
|
||||
@@ -199,13 +196,13 @@ class GitServiceGiteaMixin:
|
||||
if not target_exists:
|
||||
return f"Gitea branch not found: target branch '{to_branch}' in {owner}/{repo}"
|
||||
return None
|
||||
# #endregion _build_gitea_pr_404_detail
|
||||
# [/DEF:_build_gitea_pr_404_detail:Function]
|
||||
|
||||
# #region create_gitea_pull_request [TYPE Function]
|
||||
# @BRIEF Create pull request in Gitea.
|
||||
# @PRE Config and remote URL are valid.
|
||||
# @POST Returns normalized PR metadata.
|
||||
# @RETURN Dict[str, Any]
|
||||
# [DEF:create_gitea_pull_request:Function]
|
||||
# @PURPOSE: Create pull request in Gitea.
|
||||
# @PRE: Config and remote URL are valid.
|
||||
# @POST: Returns normalized PR metadata.
|
||||
# @RETURN: Dict[str, Any]
|
||||
async def create_gitea_pull_request(
|
||||
self, server_url: str, pat: str, remote_url: str, from_branch: str, to_branch: str,
|
||||
title: str, description: Optional[str] = None,
|
||||
@@ -223,7 +220,10 @@ class GitServiceGiteaMixin:
|
||||
exc.status_code == 404 and fallback_url and fallback_url != normalized_primary
|
||||
)
|
||||
if should_retry_with_fallback:
|
||||
log.explore(f"Primary Gitea URL not found, retrying with remote host: {fallback_url}", error=fallback_url)
|
||||
logger.warning(
|
||||
"[create_gitea_pull_request][Action] Primary Gitea URL not found, retrying with remote host: %s",
|
||||
fallback_url,
|
||||
)
|
||||
active_server_url = fallback_url
|
||||
try:
|
||||
data = await self._gitea_request("POST", active_server_url, pat, endpoint, payload=req_payload)
|
||||
@@ -254,6 +254,6 @@ class GitServiceGiteaMixin:
|
||||
"url": data.get("html_url") or data.get("url"),
|
||||
"status": data.get("state") or "open",
|
||||
}
|
||||
# #endregion create_gitea_pull_request
|
||||
# [/DEF:create_gitea_pull_request:Function]
|
||||
# #endregion GitServiceGiteaMixin
|
||||
# #endregion GitServiceGiteaMixin
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# #region GitServiceMergeMixin [C:3] [TYPE Module] [SEMANTICS git, merge, conflicts, resolution, promote]
|
||||
# @LAYER: Infra
|
||||
# @BRIEF Merge operations for GitService — conflict detection, resolution, abort, continue, and direct promote.
|
||||
# @LAYER Infra
|
||||
# @RELATION USED_BY -> [GitService]
|
||||
|
||||
import os
|
||||
@@ -16,8 +16,8 @@ from src.core.logger import logger, belief_scope
|
||||
# #region GitServiceMergeMixin [C:3] [TYPE Class]
|
||||
# @BRIEF Mixin providing merge operations for GitService.
|
||||
class GitServiceMergeMixin:
|
||||
# #region _read_blob_text [TYPE Function]
|
||||
# @BRIEF Read text from a Git blob.
|
||||
# [DEF:_read_blob_text:Function]
|
||||
# @PURPOSE: Read text from a Git blob.
|
||||
def _read_blob_text(self, blob: Blob) -> str:
|
||||
with belief_scope("GitService._read_blob_text"):
|
||||
if blob is None:
|
||||
@@ -26,20 +26,20 @@ class GitServiceMergeMixin:
|
||||
return blob.data_stream.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
return ""
|
||||
# #endregion _read_blob_text
|
||||
# [/DEF:_read_blob_text:Function]
|
||||
|
||||
# #region _get_unmerged_file_paths [TYPE Function]
|
||||
# @BRIEF List files with merge conflicts.
|
||||
# [DEF:_get_unmerged_file_paths:Function]
|
||||
# @PURPOSE: List files with merge conflicts.
|
||||
def _get_unmerged_file_paths(self, repo: Repo) -> List[str]:
|
||||
with belief_scope("GitService._get_unmerged_file_paths"):
|
||||
try:
|
||||
return sorted(list(repo.index.unmerged_blobs().keys()))
|
||||
except Exception:
|
||||
return []
|
||||
# #endregion _get_unmerged_file_paths
|
||||
# [/DEF:_get_unmerged_file_paths:Function]
|
||||
|
||||
# #region _build_unfinished_merge_payload [TYPE Function]
|
||||
# @BRIEF Build payload for unfinished merge state.
|
||||
# [DEF:_build_unfinished_merge_payload:Function]
|
||||
# @PURPOSE: Build payload for unfinished merge state.
|
||||
def _build_unfinished_merge_payload(self, repo: Repo) -> Dict[str, Any]:
|
||||
with belief_scope("GitService._build_unfinished_merge_payload"):
|
||||
merge_head_path = os.path.join(repo.git_dir, "MERGE_HEAD")
|
||||
@@ -83,10 +83,10 @@ class GitServiceMergeMixin:
|
||||
],
|
||||
"manual_commands": ["git status", "git add <resolved-files>", 'git commit -m "resolve merge conflicts"', "git merge --abort"],
|
||||
}
|
||||
# #endregion _build_unfinished_merge_payload
|
||||
# [/DEF:_build_unfinished_merge_payload:Function]
|
||||
|
||||
# #region get_merge_status [TYPE Function]
|
||||
# @BRIEF Get current merge status for a dashboard repository.
|
||||
# [DEF:get_merge_status:Function]
|
||||
# @PURPOSE: Get current merge status for a dashboard repository.
|
||||
def get_merge_status(self, dashboard_id: int) -> Dict[str, Any]:
|
||||
with belief_scope("GitService.get_merge_status"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
@@ -116,10 +116,10 @@ class GitServiceMergeMixin:
|
||||
"merge_message_preview": payload["merge_message_preview"],
|
||||
"conflicts_count": int(payload.get("conflicts_count") or 0),
|
||||
}
|
||||
# #endregion get_merge_status
|
||||
# [/DEF:get_merge_status:Function]
|
||||
|
||||
# #region get_merge_conflicts [TYPE Function]
|
||||
# @BRIEF List all files with conflicts and their contents.
|
||||
# [DEF:get_merge_conflicts:Function]
|
||||
# @PURPOSE: List all files with conflicts and their contents.
|
||||
def get_merge_conflicts(self, dashboard_id: int) -> List[Dict[str, Any]]:
|
||||
with belief_scope("GitService.get_merge_conflicts"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
@@ -139,10 +139,10 @@ class GitServiceMergeMixin:
|
||||
"theirs": self._read_blob_text(theirs_blob) if theirs_blob else "",
|
||||
})
|
||||
return sorted(conflicts, key=lambda item: item["file_path"])
|
||||
# #endregion get_merge_conflicts
|
||||
# [/DEF:get_merge_conflicts:Function]
|
||||
|
||||
# #region resolve_merge_conflicts [TYPE Function]
|
||||
# @BRIEF Resolve conflicts using specified strategy.
|
||||
# [DEF:resolve_merge_conflicts:Function]
|
||||
# @PURPOSE: Resolve conflicts using specified strategy.
|
||||
def resolve_merge_conflicts(self, dashboard_id: int, resolutions: List[Dict[str, Any]]) -> List[str]:
|
||||
with belief_scope("GitService.resolve_merge_conflicts"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
@@ -172,10 +172,10 @@ class GitServiceMergeMixin:
|
||||
repo.git.add(file_path)
|
||||
resolved_files.append(file_path)
|
||||
return resolved_files
|
||||
# #endregion resolve_merge_conflicts
|
||||
# [/DEF:resolve_merge_conflicts:Function]
|
||||
|
||||
# #region abort_merge [TYPE Function]
|
||||
# @BRIEF Abort ongoing merge.
|
||||
# [DEF:abort_merge:Function]
|
||||
# @PURPOSE: Abort ongoing merge.
|
||||
def abort_merge(self, dashboard_id: int) -> Dict[str, Any]:
|
||||
with belief_scope("GitService.abort_merge"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
@@ -188,10 +188,10 @@ class GitServiceMergeMixin:
|
||||
return {"status": "no_merge_in_progress"}
|
||||
raise HTTPException(status_code=409, detail=f"Cannot abort merge: {details}")
|
||||
return {"status": "aborted"}
|
||||
# #endregion abort_merge
|
||||
# [/DEF:abort_merge:Function]
|
||||
|
||||
# #region continue_merge [TYPE Function]
|
||||
# @BRIEF Finalize merge after conflict resolution.
|
||||
# [DEF:continue_merge:Function]
|
||||
# @PURPOSE: Finalize merge after conflict resolution.
|
||||
def continue_merge(self, dashboard_id: int, message: Optional[str] = None) -> Dict[str, Any]:
|
||||
with belief_scope("GitService.continue_merge"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
@@ -223,13 +223,13 @@ class GitServiceMergeMixin:
|
||||
except Exception:
|
||||
commit_hash = ""
|
||||
return {"status": "committed", "commit_hash": commit_hash}
|
||||
# #endregion continue_merge
|
||||
# [/DEF:continue_merge:Function]
|
||||
|
||||
# #region promote_direct_merge [TYPE Function]
|
||||
# @BRIEF Perform direct merge between branches in local repo and push target branch.
|
||||
# @PRE Repository exists and both branches are valid.
|
||||
# @POST Target branch contains merged changes from source branch.
|
||||
# @RETURN Dict[str, Any]
|
||||
# [DEF:promote_direct_merge:Function]
|
||||
# @PURPOSE: Perform direct merge between branches in local repo and push target branch.
|
||||
# @PRE: Repository exists and both branches are valid.
|
||||
# @POST: Target branch contains merged changes from source branch.
|
||||
# @RETURN: Dict[str, Any]
|
||||
def promote_direct_merge(self, dashboard_id: int, from_branch: str, to_branch: str) -> Dict[str, Any]:
|
||||
with belief_scope("GitService.promote_direct_merge"):
|
||||
if not from_branch or not to_branch:
|
||||
@@ -270,6 +270,6 @@ class GitServiceMergeMixin:
|
||||
raise HTTPException(status_code=409, detail=f"Merge conflict during direct promote: {message}")
|
||||
raise HTTPException(status_code=500, detail=f"Direct promote failed: {message}")
|
||||
return {"mode": "direct", "from_branch": source, "to_branch": target, "status": "merged"}
|
||||
# #endregion promote_direct_merge
|
||||
# [/DEF:promote_direct_merge:Function]
|
||||
# #endregion GitServiceMergeMixin
|
||||
# #endregion GitServiceMergeMixin
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# #region GitServiceRemoteMixin [C:3] [TYPE Module] [SEMANTICS git, github, gitlab, api, provider, repository, pull_request, merge_request]
|
||||
# @LAYER: Infra
|
||||
# @BRIEF GitHub and GitLab provider operations for GitService — repository creation and PR/MR creation.
|
||||
# @LAYER Infra
|
||||
# @RELATION USED_BY -> [GitService]
|
||||
|
||||
import httpx
|
||||
@@ -10,15 +10,14 @@ from fastapi import HTTPException
|
||||
from src.core.logger import logger, belief_scope
|
||||
|
||||
|
||||
# [DEF:GitServiceGithubMixin:Class]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Mixin providing GitHub API operations for GitService.
|
||||
# #region GitServiceGithubMixin [C:3] [TYPE Class]
|
||||
# @BRIEF Mixin providing GitHub API operations for GitService.
|
||||
class GitServiceGithubMixin:
|
||||
# #region create_github_repository [TYPE Function]
|
||||
# @BRIEF Create repository in GitHub or GitHub Enterprise.
|
||||
# @PRE PAT has repository create permission.
|
||||
# @POST Returns created repository payload.
|
||||
# @RETURN dict
|
||||
# [DEF:create_github_repository:Function]
|
||||
# @PURPOSE: Create repository in GitHub or GitHub Enterprise.
|
||||
# @PRE: PAT has repository create permission.
|
||||
# @POST: Returns created repository payload.
|
||||
# @RETURN: dict
|
||||
async def create_github_repository(
|
||||
self, server_url: str, pat: str, name: str, private: bool = True,
|
||||
description: Optional[str] = None, auto_init: bool = True, default_branch: Optional[str] = "main",
|
||||
@@ -52,13 +51,13 @@ class GitServiceGithubMixin:
|
||||
pass
|
||||
raise HTTPException(status_code=response.status_code, detail=f"GitHub API error: {detail}")
|
||||
return response.json()
|
||||
# #endregion create_github_repository
|
||||
# [/DEF:create_github_repository:Function]
|
||||
|
||||
# #region create_github_pull_request [TYPE Function]
|
||||
# @BRIEF Create pull request in GitHub or GitHub Enterprise.
|
||||
# @PRE Config and remote URL are valid.
|
||||
# @POST Returns normalized PR metadata.
|
||||
# @RETURN Dict[str, Any]
|
||||
# [DEF:create_github_pull_request:Function]
|
||||
# @PURPOSE: Create pull request in GitHub or GitHub Enterprise.
|
||||
# @PRE: Config and remote URL are valid.
|
||||
# @POST: Returns normalized PR metadata.
|
||||
# @RETURN: Dict[str, Any]
|
||||
async def create_github_pull_request(
|
||||
self, server_url: str, pat: str, remote_url: str, from_branch: str, to_branch: str,
|
||||
title: str, description: Optional[str] = None, draft: bool = False,
|
||||
@@ -92,17 +91,17 @@ class GitServiceGithubMixin:
|
||||
raise HTTPException(status_code=response.status_code, detail=f"GitHub API error: {detail}")
|
||||
data = response.json()
|
||||
return {"id": data.get("number") or data.get("id"), "url": data.get("html_url") or data.get("url"), "status": data.get("state") or "open"}
|
||||
# #endregion create_github_pull_request
|
||||
# [/DEF:create_github_pull_request:Function]
|
||||
|
||||
|
||||
# #region GitServiceGitlabMixin [C:3] [TYPE Class]
|
||||
# @BRIEF Mixin providing GitLab API operations for GitService.
|
||||
class GitServiceGitlabMixin:
|
||||
# #region create_gitlab_repository [TYPE Function]
|
||||
# @BRIEF Create repository(project) in GitLab.
|
||||
# @PRE PAT has api scope.
|
||||
# @POST Returns created repository payload.
|
||||
# @RETURN dict
|
||||
# [DEF:create_gitlab_repository:Function]
|
||||
# @PURPOSE: Create repository(project) in GitLab.
|
||||
# @PRE: PAT has api scope.
|
||||
# @POST: Returns created repository payload.
|
||||
# @RETURN: dict
|
||||
async def create_gitlab_repository(
|
||||
self, server_url: str, pat: str, name: str, private: bool = True,
|
||||
description: Optional[str] = None, auto_init: bool = True, default_branch: Optional[str] = "main",
|
||||
@@ -142,13 +141,13 @@ class GitServiceGitlabMixin:
|
||||
if "full_name" not in data:
|
||||
data["full_name"] = data.get("path_with_namespace") or data.get("name")
|
||||
return data
|
||||
# #endregion create_gitlab_repository
|
||||
# [/DEF:create_gitlab_repository:Function]
|
||||
|
||||
# #region create_gitlab_merge_request [TYPE Function]
|
||||
# @BRIEF Create merge request in GitLab.
|
||||
# @PRE Config and remote URL are valid.
|
||||
# @POST Returns normalized MR metadata.
|
||||
# @RETURN Dict[str, Any]
|
||||
# [DEF:create_gitlab_merge_request:Function]
|
||||
# @PURPOSE: Create merge request in GitLab.
|
||||
# @PRE: Config and remote URL are valid.
|
||||
# @POST: Returns normalized MR metadata.
|
||||
# @RETURN: Dict[str, Any]
|
||||
async def create_gitlab_merge_request(
|
||||
self, server_url: str, pat: str, remote_url: str, from_branch: str, to_branch: str,
|
||||
title: str, description: Optional[str] = None, remove_source_branch: bool = False,
|
||||
@@ -178,6 +177,7 @@ class GitServiceGitlabMixin:
|
||||
raise HTTPException(status_code=response.status_code, detail=f"GitLab API error: {detail}")
|
||||
data = response.json()
|
||||
return {"id": data.get("iid") or data.get("id"), "url": data.get("web_url") or data.get("url"), "status": data.get("state") or "opened"}
|
||||
# #endregion create_gitlab_merge_request
|
||||
# [/DEF:create_gitlab_merge_request:Function]
|
||||
# #endregion GitServiceGitlabMixin
|
||||
# #endregion GitServiceRemoteMixin
|
||||
# #endregion GitServiceRemoteMixin
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
# #region GitServiceStatusMixin [C:3] [TYPE Module] [SEMANTICS git, status, diff, history, porcelain]
|
||||
# @LAYER: Infra
|
||||
# @BRIEF Status, diff, and commit history operations for GitService using git status --porcelain to avoid --cached flag issues.
|
||||
# @LAYER Infra
|
||||
# @RELATION USED_BY -> [GitService]
|
||||
|
||||
from typing import Any, Dict, List
|
||||
from datetime import datetime
|
||||
from git import Repo
|
||||
from src.core.cot_logger import MarkerLogger
|
||||
from src.core.logger import belief_scope
|
||||
|
||||
log = MarkerLogger("GitStatus")
|
||||
from src.core.logger import logger, belief_scope
|
||||
|
||||
|
||||
# #region GitServiceStatusMixin [C:3] [TYPE Class]
|
||||
# @BRIEF Mixin providing repository status, diff, and commit history for GitService.
|
||||
class GitServiceStatusMixin:
|
||||
# #region _parse_status_porcelain [C:2] [TYPE Function]
|
||||
# @BRIEF Parse git status --porcelain output into staged, modified, and untracked file lists.
|
||||
# @PRE `repo` is an open GitPython Repo instance.
|
||||
# @POST Returns (staged, modified, untracked) tuple of file path lists.
|
||||
# @RATIONALE Avoids repo.is_dirty() / repo.index.diff("HEAD") which internally
|
||||
# [DEF:_parse_status_porcelain:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Parse git status --porcelain output into staged, modified, and untracked file lists.
|
||||
# @PRE: `repo` is an open GitPython Repo instance.
|
||||
# @POST: Returns (staged, modified, untracked) tuple of file path lists.
|
||||
# @RATIONALE: Avoids repo.is_dirty() / repo.index.diff("HEAD") which internally
|
||||
# call git diff --cached, a flag unsupported in some Git environments (exit 129).
|
||||
# Using git status --porcelain is self-contained and avoids the --cached flag entirely.
|
||||
def _parse_status_porcelain(self, repo) -> tuple[list[str], list[str], list[str]]:
|
||||
@@ -30,7 +28,7 @@ class GitServiceStatusMixin:
|
||||
try:
|
||||
output = repo.git.status("--porcelain")
|
||||
except Exception:
|
||||
log.explore("git status --porcelain failed", error="git status --porcelain command failed")
|
||||
logger.warning("[_parse_status_porcelain][Coherence:Failed] git status --porcelain failed")
|
||||
return staged, modified, untracked
|
||||
for line in output.split("\n"):
|
||||
if not line:
|
||||
@@ -52,13 +50,13 @@ class GitServiceStatusMixin:
|
||||
if path not in modified:
|
||||
modified.append(path)
|
||||
return staged, modified, untracked
|
||||
# #endregion _parse_status_porcelain
|
||||
# [/DEF:_parse_status_porcelain:Function]
|
||||
|
||||
# #region get_status [TYPE Function]
|
||||
# @BRIEF Get current repository status (dirty files, untracked, etc.)
|
||||
# @PRE Repository for dashboard_id exists.
|
||||
# @POST Returns a dictionary representing the Git status.
|
||||
# @RETURN dict
|
||||
# [DEF:get_status:Function]
|
||||
# @PURPOSE: Get current repository status (dirty files, untracked, etc.)
|
||||
# @PRE: Repository for dashboard_id exists.
|
||||
# @POST: Returns a dictionary representing the Git status.
|
||||
# @RETURN: dict
|
||||
def get_status(self, dashboard_id: int) -> dict:
|
||||
with belief_scope("GitService.get_status"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
@@ -112,10 +110,10 @@ class GitServiceStatusMixin:
|
||||
"is_diverged": is_diverged,
|
||||
"sync_state": sync_state,
|
||||
}
|
||||
# #endregion get_status
|
||||
# [/DEF:get_status:Function]
|
||||
|
||||
# #region get_diff [TYPE Function]
|
||||
# @BRIEF Generate diff for a file or the whole repository.
|
||||
# [DEF:get_diff:Function]
|
||||
# @PURPOSE: Generate diff for a file or the whole repository.
|
||||
# @PARAM: file_path (str) - Optional specific file.
|
||||
# @PARAM: staged (bool) - Whether to show staged changes.
|
||||
# @PRE: Repository for dashboard_id exists.
|
||||
@@ -130,10 +128,10 @@ class GitServiceStatusMixin:
|
||||
if file_path:
|
||||
return repo.git.diff(*diff_args, "--", file_path)
|
||||
return repo.git.diff(*diff_args)
|
||||
# #endregion get_diff
|
||||
# [/DEF:get_diff:Function]
|
||||
|
||||
# #region get_commit_history [TYPE Function]
|
||||
# @BRIEF Retrieve commit history for a repository.
|
||||
# [DEF:get_commit_history:Function]
|
||||
# @PURPOSE: Retrieve commit history for a repository.
|
||||
# @PARAM: limit (int) - Max number of commits to return.
|
||||
# @PRE: Repository for dashboard_id exists.
|
||||
# @POST: Returns a list of dictionaries for each commit in history.
|
||||
@@ -155,8 +153,9 @@ class GitServiceStatusMixin:
|
||||
"files_changed": list(commit.stats.files.keys())
|
||||
})
|
||||
except Exception as e:
|
||||
log.explore(f"Could not retrieve commit history for dashboard {dashboard_id}: {e}", error=str(e))
|
||||
logger.warning(f"[get_commit_history][Action] Could not retrieve commit history for dashboard {dashboard_id}: {e}")
|
||||
return []
|
||||
return commits
|
||||
# #endregion get_commit_history
|
||||
# [/DEF:get_commit_history:Function]
|
||||
# #endregion GitServiceStatusMixin
|
||||
# #endregion GitServiceStatusMixin
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# #region GitServiceSyncMixin [C:3] [TYPE Module] [SEMANTICS git, push, pull, sync, remote]
|
||||
# @LAYER: Infra
|
||||
# @BRIEF Push and pull operations for GitService with origin host auto-alignment.
|
||||
# @LAYER Infra
|
||||
# @RELATION USED_BY -> [GitService]
|
||||
# @RELATION DEPENDS_ON -> [GitServiceUrlMixin]
|
||||
|
||||
@@ -10,30 +10,27 @@ from git import Repo
|
||||
from git.exc import GitCommandError
|
||||
from fastapi import HTTPException
|
||||
from src.core.database import SessionLocal
|
||||
from src.core.logger import belief_scope
|
||||
from src.core.cot_logger import MarkerLogger
|
||||
|
||||
log = MarkerLogger("GitSync")
|
||||
from src.core.logger import logger, belief_scope
|
||||
from src.models.git import GitRepository, GitServerConfig
|
||||
|
||||
|
||||
# #region GitServiceSyncMixin [C:3] [TYPE Class]
|
||||
# @BRIEF Mixin providing push and pull operations with origin host alignment.
|
||||
class GitServiceSyncMixin:
|
||||
# #region push_changes [TYPE Function]
|
||||
# @BRIEF Push local commits to remote.
|
||||
# @PRE Repository exists and has an 'origin' remote.
|
||||
# @POST Local branch commits are pushed to origin.
|
||||
# [DEF:push_changes:Function]
|
||||
# @PURPOSE: Push local commits to remote.
|
||||
# @PRE: Repository exists and has an 'origin' remote.
|
||||
# @POST: Local branch commits are pushed to origin.
|
||||
def push_changes(self, dashboard_id: int):
|
||||
with belief_scope("GitService.push_changes"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
if not repo.heads:
|
||||
log.explore("No local branches to push", payload={"dashboard_id": dashboard_id}, error="Repository has no local branch heads to push")
|
||||
logger.warning(f"[push_changes][Coherence:Failed] No local branches to push for dashboard {dashboard_id}")
|
||||
return
|
||||
try:
|
||||
origin = repo.remote(name='origin')
|
||||
except ValueError:
|
||||
log.explore("Remote 'origin' not found", payload={"dashboard_id": dashboard_id}, error="Git remote 'origin' is not configured for this repository")
|
||||
logger.error(f"[push_changes][Coherence:Failed] Remote 'origin' not found for dashboard {dashboard_id}")
|
||||
raise HTTPException(status_code=400, detail="Remote 'origin' not configured")
|
||||
try:
|
||||
origin_urls = list(origin.urls)
|
||||
@@ -63,7 +60,10 @@ class GitServiceSyncMixin:
|
||||
finally:
|
||||
session.close()
|
||||
except Exception as diag_error:
|
||||
log.explore("Failed to load repository binding diagnostics", payload={"dashboard_id": dashboard_id}, error=str(diag_error))
|
||||
logger.warning(
|
||||
"[push_changes][Action] Failed to load repository binding diagnostics for dashboard %s: %s",
|
||||
dashboard_id, diag_error,
|
||||
)
|
||||
realigned_origin_url = self._align_origin_host_with_config(
|
||||
dashboard_id=dashboard_id,
|
||||
origin=origin,
|
||||
@@ -75,17 +75,13 @@ class GitServiceSyncMixin:
|
||||
origin_urls = list(origin.urls)
|
||||
except Exception:
|
||||
origin_urls = []
|
||||
log.reason(
|
||||
"Push diagnostics",
|
||||
payload={
|
||||
"dashboard_id": dashboard_id, "config_id": binding_config_id,
|
||||
"config_url": binding_config_url, "binding_remote_url": binding_remote_url,
|
||||
"origin_urls": origin_urls, "origin_realigned": bool(realigned_origin_url),
|
||||
},
|
||||
logger.info(
|
||||
"[push_changes][Action] Push diagnostics dashboard=%s config_id=%s config_url=%s binding_remote_url=%s origin_urls=%s origin_realigned=%s",
|
||||
dashboard_id, binding_config_id, binding_config_url, binding_remote_url, origin_urls, bool(realigned_origin_url),
|
||||
)
|
||||
try:
|
||||
current_branch = repo.active_branch
|
||||
log.reason("Pushing branch", payload={"branch": current_branch.name, "origin": True})
|
||||
logger.info(f"[push_changes][Action] Pushing branch {current_branch.name} to origin")
|
||||
tracking_branch = None
|
||||
try:
|
||||
tracking_branch = current_branch.tracking_branch()
|
||||
@@ -97,7 +93,7 @@ class GitServiceSyncMixin:
|
||||
push_info = origin.push(refspec=f'{current_branch.name}:{current_branch.name}')
|
||||
for info in push_info:
|
||||
if info.flags & info.ERROR:
|
||||
log.explore("Error pushing ref", payload={"ref": info.remote_ref_string, "summary": info.summary}, error=f"Git push error: {info.summary}")
|
||||
logger.error(f"[push_changes][Coherence:Failed] Error pushing ref {info.remote_ref_string}: {info.summary}")
|
||||
raise Exception(f"Git push error for {info.remote_ref_string}: {info.summary}")
|
||||
except GitCommandError as e:
|
||||
details = str(e)
|
||||
@@ -107,31 +103,28 @@ class GitServiceSyncMixin:
|
||||
status_code=409,
|
||||
detail="Push rejected: remote branch contains newer commits. Run Pull first, resolve conflicts if any, then push again.",
|
||||
)
|
||||
log.explore("Failed to push changes", error=str(e))
|
||||
logger.error(f"[push_changes][Coherence:Failed] Failed to push changes: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Git push failed: {details}")
|
||||
except Exception as e:
|
||||
log.explore("Failed to push changes", error=str(e))
|
||||
logger.error(f"[push_changes][Coherence:Failed] Failed to push changes: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Git push failed: {str(e)}")
|
||||
# #endregion push_changes
|
||||
# [/DEF:push_changes:Function]
|
||||
|
||||
# #region pull_changes [TYPE Function]
|
||||
# @BRIEF Pull changes from remote.
|
||||
# @PRE Repository exists and has an 'origin' remote.
|
||||
# @POST Changes from origin are pulled and merged into the active branch.
|
||||
# [DEF:pull_changes:Function]
|
||||
# @PURPOSE: Pull changes from remote.
|
||||
# @PRE: Repository exists and has an 'origin' remote.
|
||||
# @POST: Changes from origin are pulled and merged into the active branch.
|
||||
def pull_changes(self, dashboard_id: int):
|
||||
with belief_scope("GitService.pull_changes"):
|
||||
repo = self.get_repo(dashboard_id)
|
||||
merge_head_path = os.path.join(repo.git_dir, "MERGE_HEAD")
|
||||
if os.path.exists(merge_head_path):
|
||||
payload = self._build_unfinished_merge_payload(repo)
|
||||
log.explore("Unfinished merge detected", error="Unfinished merge state found",
|
||||
payload={
|
||||
"dashboard_id": dashboard_id,
|
||||
"repo_path": payload["repository_path"],
|
||||
"git_dir": payload["git_dir"],
|
||||
"branch": payload["current_branch"],
|
||||
"merge_head": payload["merge_head"],
|
||||
})
|
||||
logger.warning(
|
||||
"[pull_changes][Action] Unfinished merge detected for dashboard %s (repo_path=%s git_dir=%s branch=%s merge_head=%s merge_msg=%s)",
|
||||
dashboard_id, payload["repository_path"], payload["git_dir"],
|
||||
payload["current_branch"], payload["merge_head"], payload["merge_message_preview"],
|
||||
)
|
||||
raise HTTPException(status_code=409, detail=payload)
|
||||
try:
|
||||
origin = repo.remote(name='origin')
|
||||
@@ -140,36 +133,26 @@ class GitServiceSyncMixin:
|
||||
origin_urls = list(origin.urls)
|
||||
except Exception:
|
||||
origin_urls = []
|
||||
log.reason(
|
||||
"Pull diagnostics",
|
||||
payload={
|
||||
"dashboard_id": dashboard_id,
|
||||
"repo_path": repo.working_tree_dir,
|
||||
"branch": current_branch,
|
||||
"origin_urls": origin_urls,
|
||||
},
|
||||
logger.info(
|
||||
"[pull_changes][Action] Pull diagnostics dashboard=%s repo_path=%s branch=%s origin_urls=%s",
|
||||
dashboard_id, repo.working_tree_dir, current_branch, origin_urls,
|
||||
)
|
||||
origin.fetch(prune=True)
|
||||
remote_ref = f"origin/{current_branch}"
|
||||
has_remote_branch = any(ref.name == remote_ref for ref in repo.refs)
|
||||
log.reason(
|
||||
"Pull remote branch check",
|
||||
payload={
|
||||
"dashboard_id": dashboard_id,
|
||||
"branch": current_branch,
|
||||
"remote_ref": remote_ref,
|
||||
"exists": has_remote_branch,
|
||||
},
|
||||
logger.info(
|
||||
"[pull_changes][Action] Pull remote branch check dashboard=%s branch=%s remote_ref=%s exists=%s",
|
||||
dashboard_id, current_branch, remote_ref, has_remote_branch,
|
||||
)
|
||||
if not has_remote_branch:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Remote branch '{current_branch}' does not exist yet. Push this branch first.",
|
||||
)
|
||||
log.reason("Pulling changes", payload={"branch": current_branch})
|
||||
logger.info(f"[pull_changes][Action] Pulling changes from origin/{current_branch}")
|
||||
repo.git.pull("--no-rebase", "origin", current_branch)
|
||||
except ValueError:
|
||||
log.explore("Remote 'origin' not found", payload={"dashboard_id": dashboard_id}, error="Git remote 'origin' is not configured for this repository")
|
||||
logger.error(f"[pull_changes][Coherence:Failed] Remote 'origin' not found for dashboard {dashboard_id}")
|
||||
raise HTTPException(status_code=400, detail="Remote 'origin' not configured")
|
||||
except GitCommandError as e:
|
||||
details = str(e)
|
||||
@@ -179,13 +162,13 @@ class GitServiceSyncMixin:
|
||||
status_code=409,
|
||||
detail="Pull requires conflict resolution. Resolve conflicts in repository and repeat operation.",
|
||||
)
|
||||
log.explore("Failed to pull changes", error=str(e))
|
||||
logger.error(f"[pull_changes][Coherence:Failed] Failed to pull changes: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Git pull failed: {details}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.explore("Failed to pull changes", error=str(e))
|
||||
logger.error(f"[pull_changes][Coherence:Failed] Failed to pull changes: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Git pull failed: {str(e)}")
|
||||
# #endregion pull_changes
|
||||
# [/DEF:pull_changes:Function]
|
||||
# #endregion GitServiceSyncMixin
|
||||
# #endregion GitServiceSyncMixin
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# #region GitServiceUrlMixin [C:3] [TYPE Module] [SEMANTICS git, url, parsing, normalization, host_alignment]
|
||||
# @LAYER: Infra
|
||||
# @BRIEF URL helper mixin for GitService — parse, normalize, align, and strip credentials from Git URLs.
|
||||
# @LAYER Infra
|
||||
# @RELATION USED_BY -> [GitServiceSyncMixin]
|
||||
# @RELATION USED_BY -> [GitServiceGiteaMixin]
|
||||
# @RELATION USED_BY -> [GitServiceRemoteMixin]
|
||||
@@ -10,20 +10,17 @@ from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import quote, urlparse
|
||||
from fastapi import HTTPException
|
||||
from src.core.database import SessionLocal
|
||||
from src.core.cot_logger import MarkerLogger
|
||||
from src.core.logger import belief_scope
|
||||
|
||||
log = MarkerLogger("GitUrl")
|
||||
from src.core.logger import logger, belief_scope
|
||||
from src.models.git import GitRepository, GitServerConfig
|
||||
|
||||
# #region GitServiceUrlMixin [C:3] [TYPE Class]
|
||||
# @BRIEF URL helper methods for GitService — extract host, strip credentials, replace host, align origin, parse remote identity, derive server URL, normalize server URL.
|
||||
class GitServiceUrlMixin:
|
||||
# #region _extract_http_host [TYPE Function]
|
||||
# @BRIEF Extract normalized host[:port] from HTTP(S) URL.
|
||||
# @PRE url_value may be empty.
|
||||
# @POST Returns lowercase host token or None.
|
||||
# @RETURN Optional[str]
|
||||
# [DEF:_extract_http_host:Function]
|
||||
# @PURPOSE: Extract normalized host[:port] from HTTP(S) URL.
|
||||
# @PRE: url_value may be empty.
|
||||
# @POST: Returns lowercase host token or None.
|
||||
# @RETURN: Optional[str]
|
||||
def _extract_http_host(self, url_value: Optional[str]) -> Optional[str]:
|
||||
normalized = str(url_value or "").strip()
|
||||
if not normalized:
|
||||
@@ -40,13 +37,13 @@ class GitServiceUrlMixin:
|
||||
if parsed.port:
|
||||
return f"{host.lower()}:{parsed.port}"
|
||||
return host.lower()
|
||||
# #endregion _extract_http_host
|
||||
# [/DEF:_extract_http_host:Function]
|
||||
|
||||
# #region _strip_url_credentials [TYPE Function]
|
||||
# @BRIEF Remove credentials from URL while preserving scheme/host/path.
|
||||
# @PRE url_value may contain credentials.
|
||||
# @POST Returns URL without username/password.
|
||||
# @RETURN str
|
||||
# [DEF:_strip_url_credentials:Function]
|
||||
# @PURPOSE: Remove credentials from URL while preserving scheme/host/path.
|
||||
# @PRE: url_value may contain credentials.
|
||||
# @POST: Returns URL without username/password.
|
||||
# @RETURN: str
|
||||
def _strip_url_credentials(self, url_value: str) -> str:
|
||||
normalized = str(url_value or "").strip()
|
||||
if not normalized:
|
||||
@@ -61,13 +58,13 @@ class GitServiceUrlMixin:
|
||||
if parsed.port:
|
||||
host = f"{host}:{parsed.port}"
|
||||
return parsed._replace(netloc=host).geturl()
|
||||
# #endregion _strip_url_credentials
|
||||
# [/DEF:_strip_url_credentials:Function]
|
||||
|
||||
# #region _replace_host_in_url [TYPE Function]
|
||||
# @BRIEF Replace source URL host with host from configured server URL.
|
||||
# @PRE source_url and config_url are HTTP(S) URLs.
|
||||
# @POST Returns source URL with updated host (credentials preserved) or None.
|
||||
# @RETURN Optional[str]
|
||||
# [DEF:_replace_host_in_url:Function]
|
||||
# @PURPOSE: Replace source URL host with host from configured server URL.
|
||||
# @PRE: source_url and config_url are HTTP(S) URLs.
|
||||
# @POST: Returns source URL with updated host (credentials preserved) or None.
|
||||
# @RETURN: Optional[str]
|
||||
def _replace_host_in_url(self, source_url: Optional[str], config_url: Optional[str]) -> Optional[str]:
|
||||
source = str(source_url or "").strip()
|
||||
config = str(config_url or "").strip()
|
||||
@@ -93,13 +90,13 @@ class GitServiceUrlMixin:
|
||||
auth_part = f"{auth_part}@"
|
||||
new_netloc = f"{auth_part}{target_host}"
|
||||
return source_parsed._replace(netloc=new_netloc).geturl()
|
||||
# #endregion _replace_host_in_url
|
||||
# [/DEF:_replace_host_in_url:Function]
|
||||
|
||||
# #region _align_origin_host_with_config [TYPE Function]
|
||||
# @BRIEF Auto-align local origin host to configured Git server host when they drift.
|
||||
# @PRE origin remote exists.
|
||||
# @POST origin URL host updated and DB binding normalized when mismatch detected.
|
||||
# @RETURN Optional[str]
|
||||
# [DEF:_align_origin_host_with_config:Function]
|
||||
# @PURPOSE: Auto-align local origin host to configured Git server host when they drift.
|
||||
# @PRE: origin remote exists.
|
||||
# @POST: origin URL host updated and DB binding normalized when mismatch detected.
|
||||
# @RETURN: Optional[str]
|
||||
def _align_origin_host_with_config(
|
||||
self,
|
||||
dashboard_id: int,
|
||||
@@ -118,11 +115,17 @@ class GitServiceUrlMixin:
|
||||
aligned_url = self._replace_host_in_url(source_origin_url, config_url)
|
||||
if not aligned_url:
|
||||
return None
|
||||
log.explore(f"Host mismatch for dashboard {dashboard_id}: config_host={config_host} origin_host={origin_host}, applying origin.set_url", error=f"Host mismatch for dashboard {dashboard_id}")
|
||||
logger.warning(
|
||||
"[_align_origin_host_with_config][Action] Host mismatch for dashboard %s: config_host=%s origin_host=%s, applying origin.set_url",
|
||||
dashboard_id, config_host, origin_host,
|
||||
)
|
||||
try:
|
||||
origin.set_url(aligned_url)
|
||||
except Exception as e:
|
||||
log.explore(f"Failed to set origin URL for dashboard {dashboard_id}: {e}", error=str(e))
|
||||
logger.warning(
|
||||
"[_align_origin_host_with_config][Coherence:Failed] Failed to set origin URL for dashboard %s: %s",
|
||||
dashboard_id, e,
|
||||
)
|
||||
return None
|
||||
try:
|
||||
session = SessionLocal()
|
||||
@@ -138,15 +141,18 @@ class GitServiceUrlMixin:
|
||||
finally:
|
||||
session.close()
|
||||
except Exception as e:
|
||||
log.explore(f"Failed to persist aligned remote_url for dashboard {dashboard_id}: {e}", error=str(e))
|
||||
logger.warning(
|
||||
"[_align_origin_host_with_config][Action] Failed to persist aligned remote_url for dashboard %s: %s",
|
||||
dashboard_id, e,
|
||||
)
|
||||
return aligned_url
|
||||
# #endregion _align_origin_host_with_config
|
||||
# [/DEF:_align_origin_host_with_config:Function]
|
||||
|
||||
# #region _parse_remote_repo_identity [TYPE Function]
|
||||
# @BRIEF Parse owner/repo from remote URL for Git server API operations.
|
||||
# @PRE remote_url is a valid git URL.
|
||||
# @POST Returns owner/repo tokens.
|
||||
# @RETURN Dict[str, str]
|
||||
# [DEF:_parse_remote_repo_identity:Function]
|
||||
# @PURPOSE: Parse owner/repo from remote URL for Git server API operations.
|
||||
# @PRE: remote_url is a valid git URL.
|
||||
# @POST: Returns owner/repo tokens.
|
||||
# @RETURN: Dict[str, str]
|
||||
def _parse_remote_repo_identity(self, remote_url: str) -> Dict[str, str]:
|
||||
normalized = str(remote_url or "").strip()
|
||||
if not normalized:
|
||||
@@ -166,13 +172,13 @@ class GitServiceUrlMixin:
|
||||
repo = parts[-1]
|
||||
namespace = "/".join(parts[:-1])
|
||||
return {"owner": owner, "repo": repo, "namespace": namespace, "full_name": f"{namespace}/{repo}"}
|
||||
# #endregion _parse_remote_repo_identity
|
||||
# [/DEF:_parse_remote_repo_identity:Function]
|
||||
|
||||
# #region _derive_server_url_from_remote [TYPE Function]
|
||||
# @BRIEF Build API base URL from remote repository URL without credentials.
|
||||
# @PRE remote_url may be any git URL.
|
||||
# @POST Returns normalized http(s) base URL or None when derivation is impossible.
|
||||
# @RETURN Optional[str]
|
||||
# [DEF:_derive_server_url_from_remote:Function]
|
||||
# @PURPOSE: Build API base URL from remote repository URL without credentials.
|
||||
# @PRE: remote_url may be any git URL.
|
||||
# @POST: Returns normalized http(s) base URL or None when derivation is impossible.
|
||||
# @RETURN: Optional[str]
|
||||
def _derive_server_url_from_remote(self, remote_url: str) -> Optional[str]:
|
||||
normalized = str(remote_url or "").strip()
|
||||
if not normalized or normalized.startswith("git@"):
|
||||
@@ -186,18 +192,18 @@ class GitServiceUrlMixin:
|
||||
if parsed.port:
|
||||
netloc = f"{netloc}:{parsed.port}"
|
||||
return f"{parsed.scheme}://{netloc}".rstrip("/")
|
||||
# #endregion _derive_server_url_from_remote
|
||||
# [/DEF:_derive_server_url_from_remote:Function]
|
||||
|
||||
# #region _normalize_git_server_url [TYPE Function]
|
||||
# @BRIEF Normalize Git server URL for provider API calls.
|
||||
# @PRE raw_url is non-empty.
|
||||
# @POST Returns URL without trailing slash.
|
||||
# @RETURN str
|
||||
# [DEF:_normalize_git_server_url:Function]
|
||||
# @PURPOSE: Normalize Git server URL for provider API calls.
|
||||
# @PRE: raw_url is non-empty.
|
||||
# @POST: Returns URL without trailing slash.
|
||||
# @RETURN: str
|
||||
def _normalize_git_server_url(self, raw_url: str) -> str:
|
||||
normalized = (raw_url or "").strip()
|
||||
if not normalized:
|
||||
raise HTTPException(status_code=400, detail="Git server URL is required")
|
||||
return normalized.rstrip("/")
|
||||
# #endregion _normalize_git_server_url
|
||||
# [/DEF:_normalize_git_server_url:Function]
|
||||
# #endregion GitServiceUrlMixin
|
||||
# #endregion GitServiceUrlMixin
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# #region git_service [C:1] [TYPE Module:Tombstone]
|
||||
# [DEF:git_service:Module:Tombstone]
|
||||
# @COMPLEXITY: 1
|
||||
# @BRIEF Re-export shim — GitService has been decomposed into services/git/ package.
|
||||
# All consumers continue to import from this same path without changes.
|
||||
# @RELATION: REDIRECTS_TO -> [GitServiceModule]
|
||||
# @RELATION REDIRECTS_TO -> [GitServiceModule]
|
||||
# @RATIONALE: Monolithic GitService (2101 lines) was decomposed into 8 domain-specific mixins
|
||||
# under services/git/ to satisfy INV_7 (< 400 lines per module). This shim preserves
|
||||
# the original import path for all 5 consumers.
|
||||
# @REJECTED: Breaking 5 consumer imports to remove this shim — unacceptable migration cost.
|
||||
from src.services.git import GitService # noqa: F401
|
||||
# #endregion git_service
|
||||
# [/DEF:git_service:Module:Tombstone]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# #region health_service [C:3] [TYPE Module] [SEMANTICS health, aggregation, dashboards]
|
||||
# @BRIEF Business logic for aggregating dashboard health status from validation records.
|
||||
# @LAYER Domain/Service
|
||||
# @LAYER: Domain/Service
|
||||
# @RELATION DEPENDS_ON -> [ValidationRecord]
|
||||
# @RELATION DEPENDS_ON -> [SupersetClient]
|
||||
# @RELATION DEPENDS_ON -> [TaskCleanupService]
|
||||
@@ -25,10 +25,10 @@ def _empty_dashboard_meta() -> Dict[str, Optional[str]]:
|
||||
|
||||
# #region HealthService [C:4] [TYPE Class]
|
||||
# @BRIEF Aggregate latest dashboard validation state and manage persisted health report lifecycle.
|
||||
# @PRE Service is constructed with a live SQLAlchemy session and optional config manager.
|
||||
# @POST Exposes health summary aggregation and validation report deletion operations.
|
||||
# @SIDE_EFFECT Maintains in-memory dashboard metadata caches and may coordinate cleanup through collaborators.
|
||||
# @DATA_CONTRACT Input[Session, Optional[Any]] -> Output[HealthSummaryResponse|bool]
|
||||
# @PRE: Service is constructed with a live SQLAlchemy session and optional config manager.
|
||||
# @POST: Exposes health summary aggregation and validation report deletion operations.
|
||||
# @SIDE_EFFECT: Maintains in-memory dashboard metadata caches and may coordinate cleanup through collaborators.
|
||||
# @DATA_CONTRACT: Input[Session, Optional[Any]] -> Output[HealthSummaryResponse|bool]
|
||||
# @RELATION DEPENDS_ON -> [ValidationRecord]
|
||||
# @RELATION DEPENDS_ON -> [DashboardHealthItem]
|
||||
# @RELATION DEPENDS_ON -> [HealthSummaryResponse]
|
||||
@@ -45,29 +45,31 @@ class HealthService:
|
||||
@PURPOSE: Service for managing and querying dashboard health data.
|
||||
"""
|
||||
|
||||
# #region HealthService_init [C:3] [TYPE Function]
|
||||
# @BRIEF Initialize health service with DB session and optional config access for dashboard metadata resolution.
|
||||
# @PRE db is a valid SQLAlchemy session.
|
||||
# @POST Service is ready to aggregate summaries and delete health reports.
|
||||
# @SIDE_EFFECT Initializes per-instance dashboard metadata cache.
|
||||
# @DATA_CONTRACT Input[db: Session, config_manager: Optional[Any]] -> Output[HealthService]
|
||||
# @RELATION BINDS_TO -> [HealthService]
|
||||
# [DEF:HealthService_init:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Initialize health service with DB session and optional config access for dashboard metadata resolution.
|
||||
# @PRE: db is a valid SQLAlchemy session.
|
||||
# @POST: Service is ready to aggregate summaries and delete health reports.
|
||||
# @SIDE_EFFECT: Initializes per-instance dashboard metadata cache.
|
||||
# @DATA_CONTRACT: Input[db: Session, config_manager: Optional[Any]] -> Output[HealthService]
|
||||
# @RELATION: [BINDS_TO] ->[HealthService]
|
||||
def __init__(self, db: Session, config_manager=None):
|
||||
self.db = db
|
||||
self.config_manager = config_manager
|
||||
self._dashboard_meta_cache: Dict[Tuple[str, str], Dict[str, Optional[str]]] = {}
|
||||
|
||||
# #endregion HealthService_init
|
||||
# [/DEF:HealthService_init:Function]
|
||||
|
||||
# #region _prime_dashboard_meta_cache [C:3] [TYPE Function]
|
||||
# @BRIEF Warm dashboard slug/title cache with one Superset list fetch per environment.
|
||||
# @PRE records may contain mixed numeric and slug dashboard identifiers.
|
||||
# @POST Numeric dashboard ids for known environments are cached when discoverable.
|
||||
# @SIDE_EFFECT May call Superset dashboard list API once per referenced environment.
|
||||
# @DATA_CONTRACT Input[records: List[ValidationRecord]] -> Output[None]
|
||||
# @RELATION DEPENDS_ON -> [ValidationRecord]
|
||||
# @RELATION DEPENDS_ON -> [ConfigManager]
|
||||
# @RELATION DEPENDS_ON -> [SupersetClient]
|
||||
# [DEF:_prime_dashboard_meta_cache:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Warm dashboard slug/title cache with one Superset list fetch per environment.
|
||||
# @PRE: records may contain mixed numeric and slug dashboard identifiers.
|
||||
# @POST: Numeric dashboard ids for known environments are cached when discoverable.
|
||||
# @SIDE_EFFECT: May call Superset dashboard list API once per referenced environment.
|
||||
# @DATA_CONTRACT: Input[records: List[ValidationRecord]] -> Output[None]
|
||||
# @RELATION: [DEPENDS_ON] ->[ValidationRecord]
|
||||
# @RELATION: [DEPENDS_ON] ->[ConfigManager]
|
||||
# @RELATION: [DEPENDS_ON] ->[SupersetClient]
|
||||
def _prime_dashboard_meta_cache(self, records: List[ValidationRecord]) -> None:
|
||||
if not self.config_manager or not records:
|
||||
return
|
||||
@@ -148,13 +150,14 @@ class HealthService:
|
||||
_empty_dashboard_meta()
|
||||
)
|
||||
|
||||
# #endregion _prime_dashboard_meta_cache
|
||||
# [/DEF:_prime_dashboard_meta_cache:Function]
|
||||
|
||||
# #region _resolve_dashboard_meta [C:1] [TYPE Function]
|
||||
# @BRIEF Resolve slug/title for a dashboard referenced by persisted validation record.
|
||||
# @PRE dashboard_id may be numeric or slug-like; environment_id may be empty.
|
||||
# @POST Returns dict with `slug` and `title` keys, using cache when possible.
|
||||
# @SIDE_EFFECT Writes default cache entries for unresolved numeric dashboard ids.
|
||||
# [DEF:_resolve_dashboard_meta:Function]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: Resolve slug/title for a dashboard referenced by persisted validation record.
|
||||
# @PRE: dashboard_id may be numeric or slug-like; environment_id may be empty.
|
||||
# @POST: Returns dict with `slug` and `title` keys, using cache when possible.
|
||||
# @SIDE_EFFECT: Writes default cache entries for unresolved numeric dashboard ids.
|
||||
def _resolve_dashboard_meta(
|
||||
self, dashboard_id: str, environment_id: Optional[str]
|
||||
) -> Dict[str, Optional[str]]:
|
||||
@@ -178,16 +181,17 @@ class HealthService:
|
||||
self._dashboard_meta_cache[cache_key] = meta
|
||||
return meta
|
||||
|
||||
# #endregion _resolve_dashboard_meta
|
||||
# [/DEF:_resolve_dashboard_meta:Function]
|
||||
|
||||
# #region get_health_summary [C:3] [TYPE Function]
|
||||
# @BRIEF Aggregate latest validation status per dashboard and enrich rows with dashboard slug/title.
|
||||
# @PRE environment_id may be omitted to aggregate across all environments.
|
||||
# @POST Returns HealthSummaryResponse with counts and latest record row per dashboard.
|
||||
# @SIDE_EFFECT May call Superset API to resolve dashboard metadata.
|
||||
# @DATA_CONTRACT Input[environment_id: Optional[str]] -> Output[HealthSummaryResponse]
|
||||
# @RELATION CALLS -> [_prime_dashboard_meta_cache]
|
||||
# @RELATION CALLS -> [_resolve_dashboard_meta]
|
||||
# [DEF:get_health_summary:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Aggregate latest validation status per dashboard and enrich rows with dashboard slug/title.
|
||||
# @PRE: environment_id may be omitted to aggregate across all environments.
|
||||
# @POST: Returns HealthSummaryResponse with counts and latest record row per dashboard.
|
||||
# @SIDE_EFFECT: May call Superset API to resolve dashboard metadata.
|
||||
# @DATA_CONTRACT: Input[environment_id: Optional[str]] -> Output[HealthSummaryResponse]
|
||||
# @RELATION: [CALLS] ->[_prime_dashboard_meta_cache]
|
||||
# @RELATION: [CALLS] ->[_resolve_dashboard_meta]
|
||||
async def get_health_summary(
|
||||
self, environment_id: str = ""
|
||||
) -> HealthSummaryResponse:
|
||||
@@ -281,17 +285,18 @@ class HealthService:
|
||||
unknown_count=unknown_count,
|
||||
)
|
||||
|
||||
# #endregion get_health_summary
|
||||
# [/DEF:get_health_summary:Function]
|
||||
|
||||
# #region delete_validation_report [C:3] [TYPE Function]
|
||||
# @BRIEF Delete one persisted health report and optionally clean linked task/log artifacts.
|
||||
# @PRE record_id is a validation record identifier.
|
||||
# @POST Returns True only when a matching record was deleted.
|
||||
# @SIDE_EFFECT Deletes DB rows, optional screenshot file, and optional task/log persistence.
|
||||
# @DATA_CONTRACT Input[record_id: str, task_manager: Optional[TaskManager]] -> Output[bool]
|
||||
# @RELATION DEPENDS_ON -> [ValidationRecord]
|
||||
# @RELATION DEPENDS_ON -> [TaskManager]
|
||||
# @RELATION DEPENDS_ON -> [TaskCleanupService]
|
||||
# [DEF:delete_validation_report:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Delete one persisted health report and optionally clean linked task/log artifacts.
|
||||
# @PRE: record_id is a validation record identifier.
|
||||
# @POST: Returns True only when a matching record was deleted.
|
||||
# @SIDE_EFFECT: Deletes DB rows, optional screenshot file, and optional task/log persistence.
|
||||
# @DATA_CONTRACT: Input[record_id: str, task_manager: Optional[TaskManager]] -> Output[bool]
|
||||
# @RELATION: [DEPENDS_ON] ->[ValidationRecord]
|
||||
# @RELATION: [DEPENDS_ON] ->[TaskManager]
|
||||
# @RELATION: [DEPENDS_ON] ->[TaskCleanupService]
|
||||
def delete_validation_report(
|
||||
self, record_id: str, task_manager: Optional[TaskManager] = None
|
||||
) -> bool:
|
||||
@@ -368,7 +373,9 @@ class HealthService:
|
||||
|
||||
return True
|
||||
|
||||
# #endregion delete_validation_report
|
||||
# [/DEF:delete_validation_report:Function]
|
||||
|
||||
|
||||
# #endregion HealthService
|
||||
|
||||
# #endregion health_service
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# #region llm_prompt_templates [C:2] [TYPE Module] [SEMANTICS llm, prompts, templates, settings]
|
||||
# @BRIEF Provide default LLM prompt templates and normalization helpers for runtime usage.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT All required prompt template keys are always present after normalization.
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [backend.src.core.config_manager:Function]
|
||||
# @INVARIANT: All required prompt template keys are always present after normalization.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -79,9 +79,9 @@ DEFAULT_LLM_ASSISTANT_SETTINGS: Dict[str, str] = {
|
||||
|
||||
# #region normalize_llm_settings [C:3] [TYPE Function]
|
||||
# @BRIEF Ensure llm settings contain stable schema with prompts section and default templates.
|
||||
# @PRE llm_settings is dictionary-like value or None.
|
||||
# @POST Returned dict contains prompts with all required template keys.
|
||||
# @RELATION DEPENDS_ON -> [LLMProviderService]
|
||||
# @PRE: llm_settings is dictionary-like value or None.
|
||||
# @POST: Returned dict contains prompts with all required template keys.
|
||||
# @RELATION DEPENDS_ON -> LLMProviderService
|
||||
def normalize_llm_settings(llm_settings: Any) -> Dict[str, Any]:
|
||||
normalized: Dict[str, Any] = {
|
||||
"providers": [],
|
||||
@@ -123,9 +123,9 @@ def normalize_llm_settings(llm_settings: Any) -> Dict[str, Any]:
|
||||
|
||||
# #region is_multimodal_model [C:3] [TYPE Function]
|
||||
# @BRIEF Heuristically determine whether model supports image input required for dashboard validation.
|
||||
# @PRE model_name may be empty or mixed-case.
|
||||
# @POST Returns True when model likely supports multimodal input.
|
||||
# @RELATION DEPENDS_ON -> [LLMProviderService]
|
||||
# @PRE: model_name may be empty or mixed-case.
|
||||
# @POST: Returns True when model likely supports multimodal input.
|
||||
# @RELATION DEPENDS_ON -> LLMProviderService
|
||||
def is_multimodal_model(model_name: str, provider_type: Optional[str] = None) -> bool:
|
||||
token = (model_name or "").strip().lower()
|
||||
if not token:
|
||||
@@ -165,9 +165,9 @@ def is_multimodal_model(model_name: str, provider_type: Optional[str] = None) ->
|
||||
|
||||
# #region resolve_bound_provider_id [C:3] [TYPE Function]
|
||||
# @BRIEF Resolve provider id configured for a task binding with fallback to default provider.
|
||||
# @PRE llm_settings is normalized or raw dict from config.
|
||||
# @POST Returns configured provider id or fallback id/empty string when not defined.
|
||||
# @RELATION DEPENDS_ON -> [LLMProviderService]
|
||||
# @PRE: llm_settings is normalized or raw dict from config.
|
||||
# @POST: Returns configured provider id or fallback id/empty string when not defined.
|
||||
# @RELATION DEPENDS_ON -> LLMProviderService
|
||||
def resolve_bound_provider_id(llm_settings: Any, task_key: str) -> str:
|
||||
normalized = normalize_llm_settings(llm_settings)
|
||||
bindings = normalized.get("provider_bindings", {})
|
||||
@@ -181,9 +181,9 @@ def resolve_bound_provider_id(llm_settings: Any, task_key: str) -> str:
|
||||
|
||||
# #region render_prompt [C:3] [TYPE Function]
|
||||
# @BRIEF Render prompt template using deterministic placeholder replacement with graceful fallback.
|
||||
# @PRE template is a string and variables values are already stringifiable.
|
||||
# @POST Returns rendered prompt text with known placeholders substituted.
|
||||
# @RELATION DEPENDS_ON -> [LLMProviderService]
|
||||
# @PRE: template is a string and variables values are already stringifiable.
|
||||
# @POST: Returns rendered prompt text with known placeholders substituted.
|
||||
# @RELATION DEPENDS_ON -> LLMProviderService
|
||||
def render_prompt(template: str, variables: Dict[str, Any]) -> str:
|
||||
rendered = template
|
||||
for key, value in variables.items():
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# #region llm_provider [C:3] [TYPE Module] [SEMANTICS service, llm, provider, encryption]
|
||||
# @BRIEF Service for managing LLM provider configurations with encrypted API keys.
|
||||
# @LAYER Domain
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [LLMProvider]
|
||||
# @RELATION DEPENDS_ON -> [EncryptionManager]
|
||||
# @RELATION DEPENDS_ON -> [LLMProviderConfig]
|
||||
@@ -19,12 +19,12 @@ MASKED_API_KEY_PLACEHOLDER = "********"
|
||||
|
||||
# #region _require_fernet_key [C:5] [TYPE Function]
|
||||
# @BRIEF Load and validate the Fernet key used for secret encryption.
|
||||
# @PRE ENCRYPTION_KEY environment variable must be set to a valid Fernet key.
|
||||
# @POST Returns validated key bytes ready for Fernet initialization.
|
||||
# @SIDE_EFFECT Emits belief-state logs for missing or invalid encryption configuration.
|
||||
# @DATA_CONTRACT Input[ENCRYPTION_KEY:str] -> Output[bytes]
|
||||
# @INVARIANT Encryption initialization never falls back to a hardcoded secret.
|
||||
# @PRE: ENCRYPTION_KEY environment variable must be set to a valid Fernet key.
|
||||
# @POST: Returns validated key bytes ready for Fernet initialization.
|
||||
# @RELATION DEPENDS_ON -> [backend.src.core.logger:Function]
|
||||
# @SIDE_EFFECT: Emits belief-state logs for missing or invalid encryption configuration.
|
||||
# @DATA_CONTRACT: Input[ENCRYPTION_KEY:str] -> Output[bytes]
|
||||
# @INVARIANT: Encryption initialization never falls back to a hardcoded secret.
|
||||
def _require_fernet_key() -> bytes:
|
||||
with belief_scope("_require_fernet_key"):
|
||||
raw_key = os.getenv("ENCRYPTION_KEY", "").strip()
|
||||
@@ -52,12 +52,12 @@ def _require_fernet_key() -> bytes:
|
||||
|
||||
# #region EncryptionManager [C:5] [TYPE Class]
|
||||
# @BRIEF Handles encryption and decryption of sensitive data like API keys.
|
||||
# @PRE ENCRYPTION_KEY is configured with a valid Fernet key before instantiation.
|
||||
# @POST Manager exposes reversible encrypt/decrypt operations for persisted secrets.
|
||||
# @SIDE_EFFECT Initializes Fernet cryptography state from process environment.
|
||||
# @DATA_CONTRACT Input[str] -> Output[str]
|
||||
# @INVARIANT Uses only a validated secret key from environment.
|
||||
# @RELATION CALLS -> [_require_fernet_key]
|
||||
# @PRE: ENCRYPTION_KEY is configured with a valid Fernet key before instantiation.
|
||||
# @POST: Manager exposes reversible encrypt/decrypt operations for persisted secrets.
|
||||
# @SIDE_EFFECT: Initializes Fernet cryptography state from process environment.
|
||||
# @DATA_CONTRACT: Input[str] -> Output[str]
|
||||
# @INVARIANT: Uses only a validated secret key from environment.
|
||||
#
|
||||
# @TEST_CONTRACT: EncryptionManagerModel ->
|
||||
# {
|
||||
@@ -71,35 +71,35 @@ def _require_fernet_key() -> bytes:
|
||||
# @TEST_EDGE: empty_string_encryption -> {"data": ""}
|
||||
# @TEST_INVARIANT: symmetric_encryption -> verifies: [basic_encryption_cycle, empty_string_encryption]
|
||||
class EncryptionManager:
|
||||
# #region EncryptionManager_init [TYPE Function]
|
||||
# @BRIEF Initialize the encryption manager with a Fernet key.
|
||||
# @PRE ENCRYPTION_KEY env var must be set to a valid Fernet key.
|
||||
# @POST Fernet instance ready for encryption/decryption.
|
||||
# [DEF:EncryptionManager_init:Function]
|
||||
# @PURPOSE: Initialize the encryption manager with a Fernet key.
|
||||
# @PRE: ENCRYPTION_KEY env var must be set to a valid Fernet key.
|
||||
# @POST: Fernet instance ready for encryption/decryption.
|
||||
def __init__(self):
|
||||
self.key = _require_fernet_key()
|
||||
self.fernet = Fernet(self.key)
|
||||
|
||||
# #endregion EncryptionManager_init
|
||||
# [/DEF:EncryptionManager_init:Function]
|
||||
|
||||
# #region encrypt [TYPE Function]
|
||||
# @BRIEF Encrypt a plaintext string.
|
||||
# @PRE data must be a non-empty string.
|
||||
# @POST Returns encrypted string.
|
||||
# [DEF:encrypt:Function]
|
||||
# @PURPOSE: Encrypt a plaintext string.
|
||||
# @PRE: data must be a non-empty string.
|
||||
# @POST: Returns encrypted string.
|
||||
def encrypt(self, data: str) -> str:
|
||||
with belief_scope("encrypt"):
|
||||
return self.fernet.encrypt(data.encode()).decode()
|
||||
|
||||
# #endregion encrypt
|
||||
# [/DEF:encrypt:Function]
|
||||
|
||||
# #region decrypt [TYPE Function]
|
||||
# @BRIEF Decrypt an encrypted string.
|
||||
# @PRE encrypted_data must be a valid Fernet-encrypted string.
|
||||
# @POST Returns original plaintext string.
|
||||
# [DEF:decrypt:Function]
|
||||
# @PURPOSE: Decrypt an encrypted string.
|
||||
# @PRE: encrypted_data must be a valid Fernet-encrypted string.
|
||||
# @POST: Returns original plaintext string.
|
||||
def decrypt(self, encrypted_data: str) -> str:
|
||||
with belief_scope("decrypt"):
|
||||
return self.fernet.decrypt(encrypted_data.encode()).decode()
|
||||
|
||||
# #endregion decrypt
|
||||
# [/DEF:decrypt:Function]
|
||||
|
||||
|
||||
# #endregion EncryptionManager
|
||||
@@ -111,48 +111,51 @@ class EncryptionManager:
|
||||
# @RELATION DEPENDS_ON -> [EncryptionManager]
|
||||
# @RELATION DEPENDS_ON -> [LLMProviderConfig]
|
||||
class LLMProviderService:
|
||||
# #region LLMProviderService_init [TYPE Function]
|
||||
# @BRIEF Initialize the service with database session.
|
||||
# @PRE db must be a valid SQLAlchemy Session.
|
||||
# @POST Service ready for provider operations.
|
||||
# @RELATION DEPENDS_ON -> [EncryptionManager]
|
||||
# [DEF:LLMProviderService_init:Function]
|
||||
# @PURPOSE: Initialize the service with database session.
|
||||
# @PRE: db must be a valid SQLAlchemy Session.
|
||||
# @POST: Service ready for provider operations.
|
||||
# @RELATION: [DEPENDS_ON] ->[EncryptionManager]
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.encryption = EncryptionManager()
|
||||
|
||||
# #endregion LLMProviderService_init
|
||||
# [/DEF:LLMProviderService_init:Function]
|
||||
|
||||
# #region get_all_providers [C:3] [TYPE Function]
|
||||
# @BRIEF Returns all configured LLM providers.
|
||||
# @PRE Database connection must be active.
|
||||
# @POST Returns list of all LLMProvider records.
|
||||
# @RELATION DEPENDS_ON -> [LLMProvider]
|
||||
# [DEF:get_all_providers:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Returns all configured LLM providers.
|
||||
# @PRE: Database connection must be active.
|
||||
# @POST: Returns list of all LLMProvider records.
|
||||
# @RELATION: [DEPENDS_ON] ->[LLMProvider]
|
||||
def get_all_providers(self) -> List[LLMProvider]:
|
||||
with belief_scope("get_all_providers"):
|
||||
return self.db.query(LLMProvider).all()
|
||||
|
||||
# #endregion get_all_providers
|
||||
# [/DEF:get_all_providers:Function]
|
||||
|
||||
# #region get_provider [C:3] [TYPE Function]
|
||||
# @BRIEF Returns a single LLM provider by ID.
|
||||
# @PRE provider_id must be a valid string.
|
||||
# @POST Returns LLMProvider or None if not found.
|
||||
# @RELATION DEPENDS_ON -> [LLMProvider]
|
||||
# [DEF:get_provider:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Returns a single LLM provider by ID.
|
||||
# @PRE: provider_id must be a valid string.
|
||||
# @POST: Returns LLMProvider or None if not found.
|
||||
# @RELATION: [DEPENDS_ON] ->[LLMProvider]
|
||||
def get_provider(self, provider_id: str) -> Optional[LLMProvider]:
|
||||
with belief_scope("get_provider"):
|
||||
return (
|
||||
self.db.query(LLMProvider).filter(LLMProvider.id == provider_id).first()
|
||||
)
|
||||
|
||||
# #endregion get_provider
|
||||
# [/DEF:get_provider:Function]
|
||||
|
||||
# #region create_provider [C:3] [TYPE Function]
|
||||
# @BRIEF Creates a new LLM provider with encrypted API key.
|
||||
# @PRE config must contain valid provider configuration.
|
||||
# @POST New provider created and persisted to database.
|
||||
# @RELATION DEPENDS_ON -> [LLMProviderConfig]
|
||||
# @RELATION DEPENDS_ON -> [LLMProvider]
|
||||
# @RELATION CALLS -> [encrypt]
|
||||
# [DEF:create_provider:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Creates a new LLM provider with encrypted API key.
|
||||
# @PRE: config must contain valid provider configuration.
|
||||
# @POST: New provider created and persisted to database.
|
||||
# @RELATION: [DEPENDS_ON] ->[LLMProviderConfig]
|
||||
# @RELATION: [DEPENDS_ON] ->[LLMProvider]
|
||||
# @RELATION: [CALLS] ->[encrypt]
|
||||
def create_provider(self, config: "LLMProviderConfig") -> LLMProvider:
|
||||
with belief_scope("create_provider"):
|
||||
encrypted_key = self.encryption.encrypt(config.api_key)
|
||||
@@ -169,15 +172,16 @@ class LLMProviderService:
|
||||
self.db.refresh(db_provider)
|
||||
return db_provider
|
||||
|
||||
# #endregion create_provider
|
||||
# [/DEF:create_provider:Function]
|
||||
|
||||
# #region update_provider [C:3] [TYPE Function]
|
||||
# @BRIEF Updates an existing LLM provider.
|
||||
# @PRE provider_id must exist, config must be valid.
|
||||
# @POST Provider updated and persisted to database.
|
||||
# @RELATION DEPENDS_ON -> [LLMProviderConfig]
|
||||
# @RELATION DEPENDS_ON -> [LLMProvider]
|
||||
# @RELATION CALLS -> [encrypt]
|
||||
# [DEF:update_provider:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Updates an existing LLM provider.
|
||||
# @PRE: provider_id must exist, config must be valid.
|
||||
# @POST: Provider updated and persisted to database.
|
||||
# @RELATION: [DEPENDS_ON] ->[LLMProviderConfig]
|
||||
# @RELATION: [DEPENDS_ON] ->[LLMProvider]
|
||||
# @RELATION: [CALLS] ->[encrypt]
|
||||
def update_provider(
|
||||
self, provider_id: str, config: "LLMProviderConfig"
|
||||
) -> Optional[LLMProvider]:
|
||||
@@ -203,13 +207,14 @@ class LLMProviderService:
|
||||
self.db.refresh(db_provider)
|
||||
return db_provider
|
||||
|
||||
# #endregion update_provider
|
||||
# [/DEF:update_provider:Function]
|
||||
|
||||
# #region delete_provider [C:3] [TYPE Function]
|
||||
# @BRIEF Deletes an LLM provider.
|
||||
# @PRE provider_id must exist.
|
||||
# @POST Provider removed from database.
|
||||
# @RELATION DEPENDS_ON -> [LLMProvider]
|
||||
# [DEF:delete_provider:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Deletes an LLM provider.
|
||||
# @PRE: provider_id must exist.
|
||||
# @POST: Provider removed from database.
|
||||
# @RELATION: [DEPENDS_ON] ->[LLMProvider]
|
||||
def delete_provider(self, provider_id: str) -> bool:
|
||||
with belief_scope("delete_provider"):
|
||||
db_provider = self.get_provider(provider_id)
|
||||
@@ -219,14 +224,15 @@ class LLMProviderService:
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
# #endregion delete_provider
|
||||
# [/DEF:delete_provider:Function]
|
||||
|
||||
# #region get_decrypted_api_key [C:3] [TYPE Function]
|
||||
# @BRIEF Returns the decrypted API key for a provider.
|
||||
# @PRE provider_id must exist with valid encrypted key.
|
||||
# @POST Returns decrypted API key or None on failure.
|
||||
# @RELATION DEPENDS_ON -> [LLMProvider]
|
||||
# @RELATION CALLS -> [decrypt]
|
||||
# [DEF:get_decrypted_api_key:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Returns the decrypted API key for a provider.
|
||||
# @PRE: provider_id must exist with valid encrypted key.
|
||||
# @POST: Returns decrypted API key or None on failure.
|
||||
# @RELATION: [DEPENDS_ON] ->[LLMProvider]
|
||||
# @RELATION: [CALLS] ->[decrypt]
|
||||
def get_decrypted_api_key(self, provider_id: str) -> Optional[str]:
|
||||
with belief_scope("get_decrypted_api_key"):
|
||||
db_provider = self.get_provider(provider_id)
|
||||
@@ -251,7 +257,9 @@ class LLMProviderService:
|
||||
logger.error(f"[get_decrypted_api_key] Decryption failed: {str(e)}")
|
||||
return None
|
||||
|
||||
# #endregion get_decrypted_api_key
|
||||
# [/DEF:get_decrypted_api_key:Function]
|
||||
|
||||
|
||||
# #endregion LLMProviderService
|
||||
|
||||
# #endregion llm_provider
|
||||
|
||||
@@ -1,36 +1,35 @@
|
||||
# #region mapping_service [C:3] [TYPE Module] [SEMANTICS service, mapping, fuzzy-matching, superset]
|
||||
#
|
||||
# @BRIEF Orchestrates database fetching and fuzzy matching suggestions.
|
||||
# @LAYER Service
|
||||
# @PRE source/target environment identifiers are provided by caller.
|
||||
# @POST Exposes stateless mapping suggestion orchestration over configured environments.
|
||||
# @SIDE_EFFECT Performs remote metadata reads through Superset API clients.
|
||||
# @DATA_CONTRACT Input[source_env_id: str, target_env_id: str] -> Output[List[Dict]]
|
||||
# @INVARIANT Suggestions are based on database names.
|
||||
# @RELATION DEPENDS_ON -> [SupersetClient]
|
||||
# @RELATION DEPENDS_ON -> [suggest_mappings]
|
||||
#
|
||||
# @LAYER: Service
|
||||
# @PRE: source/target environment identifiers are provided by caller.
|
||||
# @POST: Exposes stateless mapping suggestion orchestration over configured environments.
|
||||
# @SIDE_EFFECT: Performs remote metadata reads through Superset API clients.
|
||||
# @DATA_CONTRACT: Input[source_env_id: str, target_env_id: str] -> Output[List[Dict]]
|
||||
# @RELATION DEPENDS_ON -> SupersetClient
|
||||
# @RELATION DEPENDS_ON -> suggest_mappings
|
||||
#
|
||||
# @INVARIANT: Suggestions are based on database names.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import List, Dict
|
||||
from ..core.logger import belief_scope
|
||||
from ..core.superset_client import SupersetClient
|
||||
from ..core.utils.matching import suggest_mappings
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
# #region MappingService [C:3] [TYPE Class]
|
||||
# @BRIEF Service for handling database mapping logic.
|
||||
# @PRE config_manager exposes get_environments() with environment objects containing id.
|
||||
# @POST Provides client resolution and mapping suggestion methods.
|
||||
# @SIDE_EFFECT Instantiates Superset clients and performs upstream metadata reads.
|
||||
# @DATA_CONTRACT Input[config_manager] -> Output[List[Dict]]
|
||||
# @RELATION DEPENDS_ON -> [SupersetClient]
|
||||
# @RELATION DEPENDS_ON -> [suggest_mappings]
|
||||
# @PRE: config_manager exposes get_environments() with environment objects containing id.
|
||||
# @POST: Provides client resolution and mapping suggestion methods.
|
||||
# @SIDE_EFFECT: Instantiates Superset clients and performs upstream metadata reads.
|
||||
# @DATA_CONTRACT: Input[config_manager] -> Output[List[Dict]]
|
||||
# @RELATION DEPENDS_ON -> SupersetClient
|
||||
# @RELATION DEPENDS_ON -> suggest_mappings
|
||||
class MappingService:
|
||||
# #region init [C:3] [TYPE Function]
|
||||
# @BRIEF Initializes the mapping service with a config manager.
|
||||
# @PRE config_manager is provided.
|
||||
# [DEF:init:Function]
|
||||
# @PURPOSE: Initializes the mapping service with a config manager.
|
||||
# @COMPLEXITY: 3
|
||||
# @PRE: config_manager is provided.
|
||||
# @PARAM: config_manager (ConfigManager) - The configuration manager.
|
||||
# @POST: Service is initialized.
|
||||
# @RELATION: DEPENDS_ON -> MappingService
|
||||
@@ -38,10 +37,11 @@ class MappingService:
|
||||
with belief_scope("MappingService.__init__"):
|
||||
self.config_manager = config_manager
|
||||
|
||||
# #endregion init
|
||||
# [/DEF:init:Function]
|
||||
|
||||
# #region _get_client [C:3] [TYPE Function]
|
||||
# @BRIEF Helper to get an initialized SupersetClient for an environment.
|
||||
# [DEF:_get_client:Function]
|
||||
# @PURPOSE: Helper to get an initialized SupersetClient for an environment.
|
||||
# @COMPLEXITY: 3
|
||||
# @PARAM: env_id (str) - The ID of the environment.
|
||||
# @PRE: environment must exist in config.
|
||||
# @POST: Returns an initialized SupersetClient.
|
||||
@@ -56,10 +56,11 @@ class MappingService:
|
||||
|
||||
return SupersetClient(env)
|
||||
|
||||
# #endregion _get_client
|
||||
# [/DEF:_get_client:Function]
|
||||
|
||||
# #region get_suggestions [C:3] [TYPE Function]
|
||||
# @BRIEF Fetches databases from both environments and returns fuzzy matching suggestions.
|
||||
# [DEF:get_suggestions:Function]
|
||||
# @PURPOSE: Fetches databases from both environments and returns fuzzy matching suggestions.
|
||||
# @COMPLEXITY: 3
|
||||
# @PARAM: source_env_id (str) - Source environment ID.
|
||||
# @PARAM: target_env_id (str) - Target environment ID.
|
||||
# @PRE: Both environments must be accessible.
|
||||
@@ -85,7 +86,9 @@ class MappingService:
|
||||
|
||||
return suggest_mappings(source_dbs, target_dbs)
|
||||
|
||||
# #endregion get_suggestions
|
||||
# [/DEF:get_suggestions:Function]
|
||||
|
||||
|
||||
# #endregion MappingService
|
||||
|
||||
# #endregion mapping_service
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# #region test_notification_service [C:2] [TYPE Module]
|
||||
# @BRIEF Unit tests for NotificationService routing and dispatch logic.
|
||||
# @RELATION TESTS -> [NotificationService:Class]
|
||||
# [DEF:test_notification_service:Module]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Unit tests for NotificationService routing and dispatch logic.
|
||||
# @RELATION: TESTS ->[NotificationService:Class]
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
@@ -117,4 +118,4 @@ async def test_dispatch_report_calls_providers(service, mock_db):
|
||||
service._providers["TELEGRAM"].send.assert_called_once()
|
||||
service._providers["SMTP"].send.assert_called_once()
|
||||
|
||||
# #endregion test_notification_service
|
||||
# [/DEF:test_notification_service:Module]
|
||||
@@ -1,20 +1,20 @@
|
||||
# #region providers [C:5] [TYPE Module] [SEMANTICS notifications, providers, smtp, slack, telegram, abstraction]
|
||||
#
|
||||
# @BRIEF Defines abstract base and concrete implementations for external notification delivery.
|
||||
# @LAYER Infra
|
||||
# @PRE Provider configuration dictionaries are supplied by trusted configuration sources.
|
||||
# @POST Each provider exposes async send contract returning boolean delivery outcome.
|
||||
# @SIDE_EFFECT Performs outbound network I/O to SMTP or HTTP endpoints.
|
||||
# @DATA_CONTRACT Input[target, subject, body, context?] -> Output[bool]
|
||||
# @INVARIANT Concrete providers preserve boolean send contract and swallow transport exceptions into False.
|
||||
# @INVARIANT Providers must be stateless and resilient to network failures.
|
||||
# @INVARIANT Sensitive credentials must be handled via encrypted config.
|
||||
# @RELATION DEPENDED_ON_BY -> [NotificationService]
|
||||
# @RELATION DEPENDS_ON -> [NotificationProvider]
|
||||
# @RELATION DEPENDS_ON -> [SMTPProvider]
|
||||
# @RELATION DEPENDS_ON -> [TelegramProvider]
|
||||
# @RELATION DEPENDS_ON -> [SlackProvider]
|
||||
# @LAYER: Infra
|
||||
# @PRE: Provider configuration dictionaries are supplied by trusted configuration sources.
|
||||
# @POST: Each provider exposes async send contract returning boolean delivery outcome.
|
||||
# @SIDE_EFFECT: Performs outbound network I/O to SMTP or HTTP endpoints.
|
||||
# @DATA_CONTRACT: Input[target, subject, body, context?] -> Output[bool]
|
||||
# @INVARIANT: Concrete providers preserve boolean send contract and swallow transport exceptions into False.
|
||||
#
|
||||
#
|
||||
# @INVARIANT: Providers must be stateless and resilient to network failures.
|
||||
# @INVARIANT: Sensitive credentials must be handled via encrypted config.
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
# #region service [C:5] [TYPE Module] [SEMANTICS notifications, service, routing, dispatch, background-tasks]
|
||||
#
|
||||
# @BRIEF Orchestrates notification routing based on user preferences and policy context.
|
||||
# @LAYER Domain
|
||||
# @PRE channel_config is loaded
|
||||
# @POST Notification dispatched via configured providers
|
||||
# @SIDE_EFFECT Sends notifications via configured providers
|
||||
# @DATA_CONTRACT NotificationChannelConfig -> NotificationRecipient
|
||||
# @INVARIANT NotificationService maintains singleton pattern for per-channel notifications
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [NotificationProvider]
|
||||
# @RELATION DEPENDS_ON -> [SMTPProvider]
|
||||
# @RELATION DEPENDS_ON -> [TelegramProvider]
|
||||
@@ -14,7 +10,11 @@
|
||||
# @RELATION DEPENDS_ON -> [ValidationPolicy]
|
||||
# @RELATION DEPENDS_ON -> [UserDashboardPreference]
|
||||
#
|
||||
#
|
||||
# @INVARIANT: NotificationService maintains singleton pattern for per-channel notifications
|
||||
# @DATA_CONTRACT: NotificationChannelConfig -> NotificationRecipient
|
||||
# @PRE: channel_config is loaded
|
||||
# @POST: Notification dispatched via configured providers
|
||||
# @SIDE_EFFECT: Sends notifications via configured providers
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from fastapi import BackgroundTasks
|
||||
@@ -34,32 +34,34 @@ from .providers import (
|
||||
|
||||
# #region NotificationService [C:4] [TYPE Class]
|
||||
# @BRIEF Routes validation reports to appropriate users and channels.
|
||||
# @PRE Service receives a live DB session and configuration manager with notification payload settings.
|
||||
# @POST Service can resolve targets and dispatch provider sends without mutating validation records.
|
||||
# @SIDE_EFFECT Reads notification configuration, queries user preferences, and dispatches provider I/O.
|
||||
# @DATA_CONTRACT Input[ValidationRecord, Optional[ValidationPolicy], Optional[BackgroundTasks]] -> Output[None]
|
||||
# @RELATION DEPENDS_ON -> [NotificationProvider]
|
||||
# @RELATION DEPENDS_ON -> [ValidationRecord]
|
||||
# @RELATION DEPENDS_ON -> [ValidationPolicy]
|
||||
# @RELATION DEPENDS_ON -> [UserDashboardPreference]
|
||||
# @PRE: Service receives a live DB session and configuration manager with notification payload settings.
|
||||
# @POST: Service can resolve targets and dispatch provider sends without mutating validation records.
|
||||
# @SIDE_EFFECT: Reads notification configuration, queries user preferences, and dispatches provider I/O.
|
||||
# @DATA_CONTRACT: Input[ValidationRecord, Optional[ValidationPolicy], Optional[BackgroundTasks]] -> Output[None]
|
||||
class NotificationService:
|
||||
# #region NotificationService_init [C:3] [TYPE Function]
|
||||
# @BRIEF Bind DB and configuration collaborators used for provider initialization and routing.
|
||||
# @RELATION BINDS_TO -> [NotificationService]
|
||||
# @RELATION DEPENDS_ON -> [ValidationPolicy]
|
||||
# [DEF:NotificationService_init:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Bind DB and configuration collaborators used for provider initialization and routing.
|
||||
# @RELATION: [BINDS_TO] ->[NotificationService]
|
||||
# @RELATION: [DEPENDS_ON] ->[ValidationPolicy]
|
||||
def __init__(self, db: Session, config_manager: ConfigManager):
|
||||
self.db = db
|
||||
self.config_manager = config_manager
|
||||
self._providers: Dict[str, NotificationProvider] = {}
|
||||
self._initialized = False
|
||||
|
||||
# #endregion NotificationService_init
|
||||
# [/DEF:NotificationService_init:Function]
|
||||
|
||||
# #region _initialize_providers [C:3] [TYPE Function]
|
||||
# @BRIEF Materialize configured notification channel adapters once per service lifetime.
|
||||
# @RELATION DEPENDS_ON -> [SMTPProvider]
|
||||
# @RELATION DEPENDS_ON -> [TelegramProvider]
|
||||
# @RELATION DEPENDS_ON -> [SlackProvider]
|
||||
# [DEF:_initialize_providers:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Materialize configured notification channel adapters once per service lifetime.
|
||||
# @RELATION: [DEPENDS_ON] ->[SMTPProvider]
|
||||
# @RELATION: [DEPENDS_ON] ->[TelegramProvider]
|
||||
# @RELATION: [DEPENDS_ON] ->[SlackProvider]
|
||||
def _initialize_providers(self):
|
||||
if self._initialized:
|
||||
return
|
||||
@@ -78,18 +80,19 @@ class NotificationService:
|
||||
|
||||
self._initialized = True
|
||||
|
||||
# #endregion _initialize_providers
|
||||
# [/DEF:_initialize_providers:Function]
|
||||
|
||||
# #region dispatch_report [C:4] [TYPE Function]
|
||||
# @BRIEF Route one validation record to resolved owners and configured custom channels.
|
||||
# @PRE record is persisted and providers can be initialized from configuration payload.
|
||||
# @POST Eligible notification sends are scheduled in background or awaited inline.
|
||||
# @SIDE_EFFECT Schedules or performs outbound provider sends and emits notification logs.
|
||||
# @DATA_CONTRACT Input[ValidationRecord, Optional[ValidationPolicy], Optional[BackgroundTasks]] -> Output[None]
|
||||
# @RELATION CALLS -> [_initialize_providers]
|
||||
# @RELATION CALLS -> [_should_notify]
|
||||
# @RELATION CALLS -> [_resolve_targets]
|
||||
# @RELATION CALLS -> [_build_body]
|
||||
# [DEF:dispatch_report:Function]
|
||||
# @COMPLEXITY: 4
|
||||
# @PURPOSE: Route one validation record to resolved owners and configured custom channels.
|
||||
# @RELATION: [CALLS] ->[_initialize_providers]
|
||||
# @RELATION: [CALLS] ->[_should_notify]
|
||||
# @RELATION: [CALLS] ->[_resolve_targets]
|
||||
# @RELATION: [CALLS] ->[_build_body]
|
||||
# @PRE: record is persisted and providers can be initialized from configuration payload.
|
||||
# @POST: Eligible notification sends are scheduled in background or awaited inline.
|
||||
# @SIDE_EFFECT: Schedules or performs outbound provider sends and emits notification logs.
|
||||
# @DATA_CONTRACT: Input[ValidationRecord, Optional[ValidationPolicy], Optional[BackgroundTasks]] -> Output[None]
|
||||
async def dispatch_report(
|
||||
self,
|
||||
record: ValidationRecord,
|
||||
@@ -135,12 +138,13 @@ class NotificationService:
|
||||
# Fallback to sync for tests or if no background_tasks provided
|
||||
await provider.send(recipient, subject, body)
|
||||
|
||||
# #endregion dispatch_report
|
||||
# [/DEF:dispatch_report:Function]
|
||||
|
||||
# #region _should_notify [C:3] [TYPE Function]
|
||||
# @BRIEF Evaluate record status against effective alert policy.
|
||||
# @RELATION DEPENDS_ON -> [ValidationRecord]
|
||||
# @RELATION DEPENDS_ON -> [ValidationPolicy]
|
||||
# [DEF:_should_notify:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Evaluate record status against effective alert policy.
|
||||
# @RELATION: [DEPENDS_ON] ->[ValidationRecord]
|
||||
# @RELATION: [DEPENDS_ON] ->[ValidationPolicy]
|
||||
def _should_notify(
|
||||
self, record: ValidationRecord, policy: Optional[ValidationPolicy]
|
||||
) -> bool:
|
||||
@@ -152,13 +156,14 @@ class NotificationService:
|
||||
return record.status in ("WARN", "FAIL")
|
||||
return record.status == "FAIL"
|
||||
|
||||
# #endregion _should_notify
|
||||
# [/DEF:_should_notify:Function]
|
||||
|
||||
# #region _resolve_targets [C:3] [TYPE Function]
|
||||
# @BRIEF Resolve owner and policy-defined delivery targets for one validation record.
|
||||
# @RELATION CALLS -> [_find_dashboard_owners]
|
||||
# @RELATION DEPENDS_ON -> [ValidationRecord]
|
||||
# @RELATION DEPENDS_ON -> [ValidationPolicy]
|
||||
# [DEF:_resolve_targets:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Resolve owner and policy-defined delivery targets for one validation record.
|
||||
# @RELATION: [CALLS] ->[_find_dashboard_owners]
|
||||
# @RELATION: [DEPENDS_ON] ->[ValidationRecord]
|
||||
# @RELATION: [DEPENDS_ON] ->[ValidationPolicy]
|
||||
def _resolve_targets(
|
||||
self, record: ValidationRecord, policy: Optional[ValidationPolicy]
|
||||
) -> List[tuple]:
|
||||
@@ -188,12 +193,13 @@ class NotificationService:
|
||||
|
||||
return targets
|
||||
|
||||
# #endregion _resolve_targets
|
||||
# [/DEF:_resolve_targets:Function]
|
||||
|
||||
# #region _find_dashboard_owners [C:3] [TYPE Function]
|
||||
# @BRIEF Load candidate dashboard owners from persisted profile preferences.
|
||||
# @RELATION DEPENDS_ON -> [ValidationRecord]
|
||||
# @RELATION DEPENDS_ON -> [UserDashboardPreference]
|
||||
# [DEF:_find_dashboard_owners:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Load candidate dashboard owners from persisted profile preferences.
|
||||
# @RELATION: [DEPENDS_ON] ->[ValidationRecord]
|
||||
# @RELATION: [DEPENDS_ON] ->[UserDashboardPreference]
|
||||
def _find_dashboard_owners(
|
||||
self, record: ValidationRecord
|
||||
) -> List[UserDashboardPreference]:
|
||||
@@ -209,11 +215,12 @@ class NotificationService:
|
||||
.all()
|
||||
)
|
||||
|
||||
# #endregion _find_dashboard_owners
|
||||
# [/DEF:_find_dashboard_owners:Function]
|
||||
|
||||
# #region _build_body [C:2] [TYPE Function]
|
||||
# @BRIEF Format one validation record into provider-ready body text.
|
||||
# @RELATION DEPENDS_ON -> [ValidationRecord]
|
||||
# [DEF:_build_body:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Format one validation record into provider-ready body text.
|
||||
# @RELATION: [DEPENDS_ON] ->[ValidationRecord]
|
||||
def _build_body(self, record: ValidationRecord) -> str:
|
||||
return (
|
||||
f"Dashboard ID: {record.dashboard_id}\n"
|
||||
@@ -223,7 +230,9 @@ class NotificationService:
|
||||
f"Issues found: {len(record.issues)}"
|
||||
)
|
||||
|
||||
# #endregion _build_body
|
||||
# [/DEF:_build_body:Function]
|
||||
|
||||
|
||||
# #endregion NotificationService
|
||||
|
||||
# #endregion service
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# #region profile_service [C:5] [TYPE Module] [SEMANTICS profile, service, validation, ownership, filtering, superset, preferences]
|
||||
#
|
||||
# @BRIEF Orchestrates profile preference persistence, Superset account lookup, and deterministic actor matching.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT Profile ID needs to unique per-user session
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [UserDashboardPreference]
|
||||
# @RELATION DEPENDS_ON -> [ProfilePreferenceResponse]
|
||||
# @RELATION DEPENDS_ON -> [SupersetClient]
|
||||
@@ -9,7 +9,7 @@
|
||||
# @RELATION DEPENDS_ON -> [User]
|
||||
# @RELATION DEPENDS_ON -> [sqlalchemy.orm.Session]
|
||||
#
|
||||
#
|
||||
# @INVARIANT: Profile ID needs to unique per-user session
|
||||
#
|
||||
# @TEST_CONTRACT: ProfilePreferenceUpdateRequest -> ProfilePreferenceResponse
|
||||
# @TEST_FIXTURE: valid_profile_update -> {"user_id":"u-1","superset_username":"John_Doe","show_only_my_dashboards":true}
|
||||
@@ -22,7 +22,6 @@
|
||||
# @POST: Profile with updated fields populated and
|
||||
# @SIDE_EFFECT: Database read/write operations
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from datetime import datetime
|
||||
from typing import Any, Iterable, List, Optional, Sequence, Set, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -45,15 +44,14 @@ from ..schemas.profile import (
|
||||
SupersetAccountLookupResponse,
|
||||
SupersetAccountCandidate,
|
||||
)
|
||||
# [/SECTION]
|
||||
|
||||
SUPPORTED_START_PAGES = {"dashboards", "datasets", "reports"}
|
||||
SUPPORTED_DENSITIES = {"compact", "comfortable"}
|
||||
|
||||
|
||||
# #region ProfileValidationError [C:2] [TYPE Class]
|
||||
# @RELATION INHERITS -> Exception
|
||||
# @BRIEF Domain validation error for profile preference update requests.
|
||||
# @RELATION INHERITS -> [Exception]
|
||||
class ProfileValidationError(Exception):
|
||||
def __init__(self, errors: Sequence[str]):
|
||||
self.errors = list(errors)
|
||||
@@ -64,8 +62,8 @@ class ProfileValidationError(Exception):
|
||||
|
||||
|
||||
# #region EnvironmentNotFoundError [C:2] [TYPE Class]
|
||||
# @RELATION INHERITS -> Exception
|
||||
# @BRIEF Raised when environment_id from lookup request is unknown in app configuration.
|
||||
# @RELATION INHERITS -> [Exception]
|
||||
class EnvironmentNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
@@ -74,8 +72,8 @@ class EnvironmentNotFoundError(Exception):
|
||||
|
||||
|
||||
# #region ProfileAuthorizationError [C:2] [TYPE Class]
|
||||
# @RELATION INHERITS -> Exception
|
||||
# @BRIEF Raised when caller attempts cross-user preference mutation.
|
||||
# @RELATION INHERITS -> [Exception]
|
||||
class ProfileAuthorizationError(Exception):
|
||||
pass
|
||||
|
||||
@@ -84,24 +82,24 @@ class ProfileAuthorizationError(Exception):
|
||||
|
||||
|
||||
# #region ProfileService [C:5] [TYPE Class]
|
||||
# @BRIEF Implements profile preference read/update flow and Superset account lookup degradation strategy.
|
||||
# @PRE Caller provides authenticated User context for external service methods.
|
||||
# @POST Preference operations remain user-scoped and return normalized profile/lookup responses.
|
||||
# @SIDE_EFFECT Writes preference records and encrypted tokens; performs external account lookups when requested.
|
||||
# @DATA_CONTRACT Input[User,ProfilePreferenceUpdateRequest|SupersetAccountLookupRequest] -> Output[ProfilePreferenceResponse|SupersetAccountLookupResponse|bool]
|
||||
# @INVARIANT Profile data integrity maintained, cache consistency with database state
|
||||
# @RELATION DEPENDS_ON -> [sqlalchemy.orm.Session]
|
||||
# @RELATION DEPENDS_ON -> [AuthRepository]
|
||||
# @RELATION DEPENDS_ON -> [SupersetClient]
|
||||
# @RELATION DEPENDS_ON -> [SupersetAccountLookupAdapter]
|
||||
# @RELATION DEPENDS_ON -> [UserDashboardPreference]
|
||||
# @RELATION CALLS -> [discover_declared_permissions]
|
||||
# @BRIEF Implements profile preference read/update flow and Superset account lookup degradation strategy.
|
||||
# @PRE: Caller provides authenticated User context for external service methods.
|
||||
# @POST: Preference operations remain user-scoped and return normalized profile/lookup responses.
|
||||
# @SIDE_EFFECT: Writes preference records and encrypted tokens; performs external account lookups when requested.
|
||||
# @DATA_CONTRACT: Input[User,ProfilePreferenceUpdateRequest|SupersetAccountLookupRequest] -> Output[ProfilePreferenceResponse|SupersetAccountLookupResponse|bool]
|
||||
# @INVARIANT: Profile data integrity maintained, cache consistency with database state
|
||||
class ProfileService:
|
||||
# #region init [TYPE Function]
|
||||
# @BRIEF Initialize service with DB session and config manager.
|
||||
# @PRE db session is active and config_manager supports get_environments().
|
||||
# @POST Service is ready for preference persistence and lookup operations.
|
||||
# @RELATION BINDS_TO -> [ProfileService]
|
||||
# [DEF:init:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Initialize service with DB session and config manager.
|
||||
# @PRE: db session is active and config_manager supports get_environments().
|
||||
# @POST: Service is ready for preference persistence and lookup operations.
|
||||
def __init__(self, db: Session, config_manager: Any, plugin_loader: Any = None):
|
||||
self.db = db
|
||||
self.config_manager = config_manager
|
||||
@@ -109,13 +107,13 @@ class ProfileService:
|
||||
self.auth_repository = AuthRepository(db)
|
||||
self.encryption = EncryptionManager()
|
||||
|
||||
# #endregion init
|
||||
# [/DEF:init:Function]
|
||||
|
||||
# #region get_my_preference [TYPE Function]
|
||||
# @BRIEF Return current user's persisted preference or default non-configured view.
|
||||
# @PRE current_user is authenticated.
|
||||
# @POST Returned payload belongs to current_user only.
|
||||
# @RELATION BINDS_TO -> [ProfileService]
|
||||
# [DEF:get_my_preference:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Return current user's persisted preference or default non-configured view.
|
||||
# @PRE: current_user is authenticated.
|
||||
# @POST: Returned payload belongs to current_user only.
|
||||
def get_my_preference(self, current_user: User) -> ProfilePreferenceResponse:
|
||||
with belief_scope(
|
||||
"ProfileService.get_my_preference", f"user_id={current_user.id}"
|
||||
@@ -140,13 +138,13 @@ class ProfileService:
|
||||
security=security_summary,
|
||||
)
|
||||
|
||||
# #endregion get_my_preference
|
||||
# [/DEF:get_my_preference:Function]
|
||||
|
||||
# #region get_dashboard_filter_binding [TYPE Function]
|
||||
# @BRIEF Return only dashboard-filter fields required by dashboards listing hot path.
|
||||
# @PRE current_user is authenticated.
|
||||
# @POST Returns normalized username and profile-default filter toggles without security summary expansion.
|
||||
# @RELATION BINDS_TO -> [ProfileService]
|
||||
# [DEF:get_dashboard_filter_binding:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Return only dashboard-filter fields required by dashboards listing hot path.
|
||||
# @PRE: current_user is authenticated.
|
||||
# @POST: Returns normalized username and profile-default filter toggles without security summary expansion.
|
||||
def get_dashboard_filter_binding(self, current_user: User) -> dict:
|
||||
with belief_scope(
|
||||
"ProfileService.get_dashboard_filter_binding", f"user_id={current_user.id}"
|
||||
@@ -175,13 +173,13 @@ class ProfileService:
|
||||
),
|
||||
}
|
||||
|
||||
# #endregion get_dashboard_filter_binding
|
||||
# [/DEF:get_dashboard_filter_binding:Function]
|
||||
|
||||
# #region update_my_preference [TYPE Function]
|
||||
# @BRIEF Validate and persist current user's profile preference in self-scoped mode.
|
||||
# @PRE current_user is authenticated and payload is provided.
|
||||
# @POST Preference row for current_user is created/updated when validation passes.
|
||||
# @RELATION BINDS_TO -> [ProfileService]
|
||||
# [DEF:update_my_preference:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Validate and persist current user's profile preference in self-scoped mode.
|
||||
# @PRE: current_user is authenticated and payload is provided.
|
||||
# @POST: Preference row for current_user is created/updated when validation passes.
|
||||
def update_my_preference(
|
||||
self,
|
||||
current_user: User,
|
||||
@@ -324,13 +322,13 @@ class ProfileService:
|
||||
security=self._build_security_summary(current_user),
|
||||
)
|
||||
|
||||
# #endregion update_my_preference
|
||||
# [/DEF:update_my_preference:Function]
|
||||
|
||||
# #region lookup_superset_accounts [TYPE Function]
|
||||
# @BRIEF Query Superset users in selected environment and project canonical account candidates.
|
||||
# @PRE current_user is authenticated and environment_id exists.
|
||||
# @POST Returns success payload or degraded payload with warning while preserving manual fallback.
|
||||
# @RELATION BINDS_TO -> [ProfileService]
|
||||
# [DEF:lookup_superset_accounts:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Query Superset users in selected environment and project canonical account candidates.
|
||||
# @PRE: current_user is authenticated and environment_id exists.
|
||||
# @POST: Returns success payload or degraded payload with warning while preserving manual fallback.
|
||||
def lookup_superset_accounts(
|
||||
self,
|
||||
current_user: User,
|
||||
@@ -406,13 +404,13 @@ class ProfileService:
|
||||
items=[],
|
||||
)
|
||||
|
||||
# #endregion lookup_superset_accounts
|
||||
# [/DEF:lookup_superset_accounts:Function]
|
||||
|
||||
# #region matches_dashboard_actor [TYPE Function]
|
||||
# @BRIEF Apply trim+case-insensitive actor match across owners OR modified_by.
|
||||
# @PRE bound_username can be empty; owners may contain mixed payload.
|
||||
# @POST Returns True when normalized username matches owners or modified_by.
|
||||
# @RELATION BINDS_TO -> [ProfileService]
|
||||
# [DEF:matches_dashboard_actor:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Apply trim+case-insensitive actor match across owners OR modified_by.
|
||||
# @PRE: bound_username can be empty; owners may contain mixed payload.
|
||||
# @POST: Returns True when normalized username matches owners or modified_by.
|
||||
def matches_dashboard_actor(
|
||||
self,
|
||||
bound_username: Optional[str],
|
||||
@@ -432,13 +430,13 @@ class ProfileService:
|
||||
return True
|
||||
return False
|
||||
|
||||
# #endregion matches_dashboard_actor
|
||||
# [/DEF:matches_dashboard_actor:Function]
|
||||
|
||||
# #region _build_security_summary [TYPE Function]
|
||||
# @BRIEF Build read-only security snapshot with role and permission badges.
|
||||
# @PRE current_user is authenticated.
|
||||
# @POST Returns deterministic security projection for profile UI.
|
||||
# @RELATION BINDS_TO -> [ProfileService]
|
||||
# [DEF:_build_security_summary:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Build read-only security snapshot with role and permission badges.
|
||||
# @PRE: current_user is authenticated.
|
||||
# @POST: Returns deterministic security projection for profile UI.
|
||||
def _build_security_summary(self, current_user: User) -> ProfileSecuritySummary:
|
||||
role_names_set: Set[str] = set()
|
||||
roles = getattr(current_user, "roles", []) or []
|
||||
@@ -496,13 +494,13 @@ class ProfileService:
|
||||
permissions=permission_states,
|
||||
)
|
||||
|
||||
# #endregion _build_security_summary
|
||||
# [/DEF:_build_security_summary:Function]
|
||||
|
||||
# #region _collect_user_permission_pairs [TYPE Function]
|
||||
# @BRIEF Collect effective permission tuples from current user's roles.
|
||||
# @PRE current_user can include role/permission graph.
|
||||
# @POST Returns unique normalized (resource, ACTION) tuples.
|
||||
# @RELATION BINDS_TO -> [ProfileService]
|
||||
# [DEF:_collect_user_permission_pairs:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Collect effective permission tuples from current user's roles.
|
||||
# @PRE: current_user can include role/permission graph.
|
||||
# @POST: Returns unique normalized (resource, ACTION) tuples.
|
||||
def _collect_user_permission_pairs(
|
||||
self, current_user: User
|
||||
) -> Set[Tuple[str, str]]:
|
||||
@@ -517,13 +515,13 @@ class ProfileService:
|
||||
collected.add((resource, action))
|
||||
return collected
|
||||
|
||||
# #endregion _collect_user_permission_pairs
|
||||
# [/DEF:_collect_user_permission_pairs:Function]
|
||||
|
||||
# #region _format_permission_key [TYPE Function]
|
||||
# @BRIEF Convert normalized permission pair to compact UI key.
|
||||
# @PRE resource and action are normalized.
|
||||
# @POST Returns user-facing badge key.
|
||||
# @RELATION BINDS_TO -> [ProfileService]
|
||||
# [DEF:_format_permission_key:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Convert normalized permission pair to compact UI key.
|
||||
# @PRE: resource and action are normalized.
|
||||
# @POST: Returns user-facing badge key.
|
||||
def _format_permission_key(self, resource: str, action: str) -> str:
|
||||
normalized_resource = self._sanitize_text(resource) or ""
|
||||
normalized_action = str(action or "").strip().upper()
|
||||
@@ -531,13 +529,13 @@ class ProfileService:
|
||||
return normalized_resource
|
||||
return f"{normalized_resource}:{normalized_action.lower()}"
|
||||
|
||||
# #endregion _format_permission_key
|
||||
# [/DEF:_format_permission_key:Function]
|
||||
|
||||
# #region _to_preference_payload [TYPE Function]
|
||||
# @BRIEF Map ORM preference row to API DTO with token metadata.
|
||||
# @PRE preference row can contain nullable optional fields.
|
||||
# @POST Returns normalized ProfilePreference object.
|
||||
# @RELATION BINDS_TO -> [ProfileService]
|
||||
# [DEF:_to_preference_payload:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Map ORM preference row to API DTO with token metadata.
|
||||
# @PRE: preference row can contain nullable optional fields.
|
||||
# @POST: Returns normalized ProfilePreference object.
|
||||
def _to_preference_payload(
|
||||
self,
|
||||
preference: UserDashboardPreference,
|
||||
@@ -591,13 +589,13 @@ class ProfileService:
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
# #endregion _to_preference_payload
|
||||
# [/DEF:_to_preference_payload:Function]
|
||||
|
||||
# #region _mask_secret_value [TYPE Function]
|
||||
# @BRIEF Build a safe display value for sensitive secrets.
|
||||
# @PRE secret may be None or plaintext.
|
||||
# @POST Returns masked representation or None.
|
||||
# @RELATION BINDS_TO -> [ProfileService]
|
||||
# [DEF:_mask_secret_value:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Build a safe display value for sensitive secrets.
|
||||
# @PRE: secret may be None or plaintext.
|
||||
# @POST: Returns masked representation or None.
|
||||
def _mask_secret_value(self, secret: Optional[str]) -> Optional[str]:
|
||||
sanitized_secret = self._sanitize_secret(secret)
|
||||
if sanitized_secret is None:
|
||||
@@ -606,26 +604,26 @@ class ProfileService:
|
||||
return "***"
|
||||
return f"{sanitized_secret[:2]}***{sanitized_secret[-2:]}"
|
||||
|
||||
# #endregion _mask_secret_value
|
||||
# [/DEF:_mask_secret_value:Function]
|
||||
|
||||
# #region _sanitize_text [TYPE Function]
|
||||
# @BRIEF Normalize optional text into trimmed form or None.
|
||||
# @PRE value may be empty or None.
|
||||
# @POST Returns trimmed value or None.
|
||||
# @RELATION BINDS_TO -> [ProfileService]
|
||||
# [DEF:_sanitize_text:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Normalize optional text into trimmed form or None.
|
||||
# @PRE: value may be empty or None.
|
||||
# @POST: Returns trimmed value or None.
|
||||
def _sanitize_text(self, value: Optional[str]) -> Optional[str]:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
return None
|
||||
return normalized
|
||||
|
||||
# #endregion _sanitize_text
|
||||
# [/DEF:_sanitize_text:Function]
|
||||
|
||||
# #region _sanitize_secret [TYPE Function]
|
||||
# @BRIEF Normalize secret input into trimmed form or None.
|
||||
# @PRE value may be None or blank.
|
||||
# @POST Returns trimmed secret or None.
|
||||
# @RELATION BINDS_TO -> [ProfileService]
|
||||
# [DEF:_sanitize_secret:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Normalize secret input into trimmed form or None.
|
||||
# @PRE: value may be None or blank.
|
||||
# @POST: Returns trimmed secret or None.
|
||||
def _sanitize_secret(self, value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
@@ -634,13 +632,13 @@ class ProfileService:
|
||||
return None
|
||||
return normalized
|
||||
|
||||
# #endregion _sanitize_secret
|
||||
# [/DEF:_sanitize_secret:Function]
|
||||
|
||||
# #region _normalize_start_page [TYPE Function]
|
||||
# @BRIEF Normalize supported start page aliases to canonical values.
|
||||
# @PRE value may be None or alias.
|
||||
# @POST Returns one of SUPPORTED_START_PAGES.
|
||||
# @RELATION BINDS_TO -> [ProfileService]
|
||||
# [DEF:_normalize_start_page:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Normalize supported start page aliases to canonical values.
|
||||
# @PRE: value may be None or alias.
|
||||
# @POST: Returns one of SUPPORTED_START_PAGES.
|
||||
def _normalize_start_page(self, value: Optional[str]) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if normalized == "reports-logs":
|
||||
@@ -649,13 +647,13 @@ class ProfileService:
|
||||
return normalized
|
||||
return "dashboards"
|
||||
|
||||
# #endregion _normalize_start_page
|
||||
# [/DEF:_normalize_start_page:Function]
|
||||
|
||||
# #region _normalize_density [TYPE Function]
|
||||
# @BRIEF Normalize supported density aliases to canonical values.
|
||||
# @PRE value may be None or alias.
|
||||
# @POST Returns one of SUPPORTED_DENSITIES.
|
||||
# @RELATION BINDS_TO -> [ProfileService]
|
||||
# [DEF:_normalize_density:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Normalize supported density aliases to canonical values.
|
||||
# @PRE: value may be None or alias.
|
||||
# @POST: Returns one of SUPPORTED_DENSITIES.
|
||||
def _normalize_density(self, value: Optional[str]) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if normalized == "free":
|
||||
@@ -664,13 +662,13 @@ class ProfileService:
|
||||
return normalized
|
||||
return "comfortable"
|
||||
|
||||
# #endregion _normalize_density
|
||||
# [/DEF:_normalize_density:Function]
|
||||
|
||||
# #region _resolve_environment [TYPE Function]
|
||||
# @BRIEF Resolve environment model from configured environments by id.
|
||||
# @PRE environment_id is provided.
|
||||
# @POST Returns environment object when found else None.
|
||||
# @RELATION BINDS_TO -> [ProfileService]
|
||||
# [DEF:_resolve_environment:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Resolve environment model from configured environments by id.
|
||||
# @PRE: environment_id is provided.
|
||||
# @POST: Returns environment object when found else None.
|
||||
def _resolve_environment(self, environment_id: str):
|
||||
environments = self.config_manager.get_environments()
|
||||
for env in environments:
|
||||
@@ -678,36 +676,36 @@ class ProfileService:
|
||||
return env
|
||||
return None
|
||||
|
||||
# #endregion _resolve_environment
|
||||
# [/DEF:_resolve_environment:Function]
|
||||
|
||||
# #region _get_preference_row [TYPE Function]
|
||||
# @BRIEF Return persisted preference row for user or None.
|
||||
# @PRE user_id is provided.
|
||||
# @POST Returns matching row or None.
|
||||
# @RELATION BINDS_TO -> [ProfileService]
|
||||
# [DEF:_get_preference_row:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Return persisted preference row for user or None.
|
||||
# @PRE: user_id is provided.
|
||||
# @POST: Returns matching row or None.
|
||||
def _get_preference_row(self, user_id: str) -> Optional[UserDashboardPreference]:
|
||||
return self.auth_repository.get_user_dashboard_preference(str(user_id))
|
||||
|
||||
# #endregion _get_preference_row
|
||||
# [/DEF:_get_preference_row:Function]
|
||||
|
||||
# #region _get_or_create_preference_row [TYPE Function]
|
||||
# @BRIEF Return existing preference row or create new unsaved row.
|
||||
# @PRE user_id is provided.
|
||||
# @POST Returned row always contains user_id.
|
||||
# @RELATION BINDS_TO -> [ProfileService]
|
||||
# [DEF:_get_or_create_preference_row:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Return existing preference row or create new unsaved row.
|
||||
# @PRE: user_id is provided.
|
||||
# @POST: Returned row always contains user_id.
|
||||
def _get_or_create_preference_row(self, user_id: str) -> UserDashboardPreference:
|
||||
existing = self._get_preference_row(user_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
return UserDashboardPreference(user_id=str(user_id))
|
||||
|
||||
# #endregion _get_or_create_preference_row
|
||||
# [/DEF:_get_or_create_preference_row:Function]
|
||||
|
||||
# #region _build_default_preference [TYPE Function]
|
||||
# @BRIEF Build non-persisted default preference DTO for unconfigured users.
|
||||
# @PRE user_id is provided.
|
||||
# @POST Returns ProfilePreference with disabled toggle and empty username.
|
||||
# @RELATION BINDS_TO -> [ProfileService]
|
||||
# [DEF:_build_default_preference:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Build non-persisted default preference DTO for unconfigured users.
|
||||
# @PRE: user_id is provided.
|
||||
# @POST: Returns ProfilePreference with disabled toggle and empty username.
|
||||
def _build_default_preference(self, user_id: str) -> ProfilePreference:
|
||||
now = datetime.utcnow()
|
||||
return ProfilePreference(
|
||||
@@ -730,13 +728,13 @@ class ProfileService:
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
# #endregion _build_default_preference
|
||||
# [/DEF:_build_default_preference:Function]
|
||||
|
||||
# #region _validate_update_payload [TYPE Function]
|
||||
# @BRIEF Validate username/toggle constraints for preference mutation.
|
||||
# @PRE payload is provided.
|
||||
# @POST Returns validation errors list; empty list means valid.
|
||||
# @RELATION BINDS_TO -> [ProfileService]
|
||||
# [DEF:_validate_update_payload:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Validate username/toggle constraints for preference mutation.
|
||||
# @PRE: payload is provided.
|
||||
# @POST: Returns validation errors list; empty list means valid.
|
||||
def _validate_update_payload(
|
||||
self,
|
||||
superset_username: Optional[str],
|
||||
@@ -786,36 +784,36 @@ class ProfileService:
|
||||
|
||||
return errors
|
||||
|
||||
# #endregion _validate_update_payload
|
||||
# [/DEF:_validate_update_payload:Function]
|
||||
|
||||
# #region _sanitize_username [TYPE Function]
|
||||
# @BRIEF Normalize raw username into trimmed form or None for empty input.
|
||||
# @PRE value can be empty or None.
|
||||
# @POST Returns trimmed username or None.
|
||||
# @RELATION BINDS_TO -> [ProfileService]
|
||||
# [DEF:_sanitize_username:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Normalize raw username into trimmed form or None for empty input.
|
||||
# @PRE: value can be empty or None.
|
||||
# @POST: Returns trimmed username or None.
|
||||
def _sanitize_username(self, value: Optional[str]) -> Optional[str]:
|
||||
return self._sanitize_text(value)
|
||||
|
||||
# #endregion _sanitize_username
|
||||
# [/DEF:_sanitize_username:Function]
|
||||
|
||||
# #region _normalize_username [TYPE Function]
|
||||
# @BRIEF Apply deterministic trim+lower normalization for actor matching.
|
||||
# @PRE value can be empty or None.
|
||||
# @POST Returns lowercase normalized token or None.
|
||||
# @RELATION BINDS_TO -> [ProfileService]
|
||||
# [DEF:_normalize_username:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Apply deterministic trim+lower normalization for actor matching.
|
||||
# @PRE: value can be empty or None.
|
||||
# @POST: Returns lowercase normalized token or None.
|
||||
def _normalize_username(self, value: Optional[str]) -> Optional[str]:
|
||||
sanitized = self._sanitize_username(value)
|
||||
if sanitized is None:
|
||||
return None
|
||||
return sanitized.lower()
|
||||
|
||||
# #endregion _normalize_username
|
||||
# [/DEF:_normalize_username:Function]
|
||||
|
||||
# #region _normalize_owner_tokens [TYPE Function]
|
||||
# @BRIEF Normalize owners payload into deduplicated lower-cased tokens.
|
||||
# @PRE owners can be iterable of scalars or dict-like values.
|
||||
# @POST Returns list of unique normalized owner tokens.
|
||||
# @RELATION BINDS_TO -> [ProfileService]
|
||||
# [DEF:_normalize_owner_tokens:Function]
|
||||
# @RELATION: BINDS_TO -> ProfileService
|
||||
# @PURPOSE: Normalize owners payload into deduplicated lower-cased tokens.
|
||||
# @PRE: owners can be iterable of scalars or dict-like values.
|
||||
# @POST: Returns list of unique normalized owner tokens.
|
||||
def _normalize_owner_tokens(self, owners: Optional[Iterable[Any]]) -> List[str]:
|
||||
if owners is None:
|
||||
return []
|
||||
@@ -851,7 +849,7 @@ class ProfileService:
|
||||
normalized.append(token)
|
||||
return normalized
|
||||
|
||||
# #endregion _normalize_owner_tokens
|
||||
# [/DEF:_normalize_owner_tokens:Function]
|
||||
|
||||
|
||||
# #endregion ProfileService
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# #region rbac_permission_catalog [C:2] [TYPE Module]
|
||||
# @BRIEF Discovers declared RBAC permissions from API routes/plugins and synchronizes them into auth database.
|
||||
#
|
||||
# @BRIEF Discovers declared RBAC permissions from API routes/plugins and synchronizes them into auth database.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
@@ -29,9 +28,8 @@ ROUTES_DIR = Path(__file__).resolve().parent.parent / "api" / "routes"
|
||||
|
||||
# #region _iter_route_files [TYPE Function]
|
||||
# @BRIEF Iterates API route files that may contain RBAC declarations.
|
||||
# @PRE ROUTES_DIR points to backend/src/api/routes.
|
||||
# @POST Yields Python files excluding test and cache directories.
|
||||
# @RETURN Iterable[Path] - Route file paths for permission extraction.
|
||||
# @PRE: ROUTES_DIR points to backend/src/api/routes.
|
||||
# @POST: Yields Python files excluding test and cache directories.
|
||||
def _iter_route_files() -> Iterable[Path]:
|
||||
with belief_scope("rbac_permission_catalog._iter_route_files"):
|
||||
if not ROUTES_DIR.exists():
|
||||
@@ -49,9 +47,8 @@ def _iter_route_files() -> Iterable[Path]:
|
||||
|
||||
# #region _discover_route_permissions [TYPE Function]
|
||||
# @BRIEF Extracts explicit has_permission declarations from API route source code.
|
||||
# @PRE Route files are readable UTF-8 text files.
|
||||
# @POST Returns unique set of (resource, action) pairs declared in route guards.
|
||||
# @RETURN Set[Tuple[str, str]] - Permission pairs from route-level RBAC declarations.
|
||||
# @PRE: Route files are readable UTF-8 text files.
|
||||
# @POST: Returns unique set of (resource, action) pairs declared in route guards.
|
||||
def _discover_route_permissions() -> Set[Tuple[str, str]]:
|
||||
with belief_scope("rbac_permission_catalog._discover_route_permissions"):
|
||||
discovered: Set[Tuple[str, str]] = set()
|
||||
@@ -77,8 +74,8 @@ def _discover_route_permissions() -> Set[Tuple[str, str]]:
|
||||
|
||||
# #region _discover_route_permissions_cached [TYPE Function]
|
||||
# @BRIEF Cache route permission discovery because route source files are static during normal runtime.
|
||||
# @PRE None.
|
||||
# @POST Returns stable discovered route permission pairs without repeated filesystem scans.
|
||||
# @PRE: None.
|
||||
# @POST: Returns stable discovered route permission pairs without repeated filesystem scans.
|
||||
@lru_cache(maxsize=1)
|
||||
def _discover_route_permissions_cached() -> Tuple[Tuple[str, str], ...]:
|
||||
with belief_scope("rbac_permission_catalog._discover_route_permissions_cached"):
|
||||
@@ -88,9 +85,8 @@ def _discover_route_permissions_cached() -> Tuple[Tuple[str, str], ...]:
|
||||
|
||||
# #region _discover_plugin_execute_permissions [TYPE Function]
|
||||
# @BRIEF Derives dynamic task permissions of form plugin:{plugin_id}:EXECUTE from plugin registry.
|
||||
# @PRE plugin_loader is optional and may expose get_all_plugin_configs.
|
||||
# @POST Returns unique plugin EXECUTE permissions if loader is available.
|
||||
# @RETURN Set[Tuple[str, str]] - Permission pairs derived from loaded plugin IDs.
|
||||
# @PRE: plugin_loader is optional and may expose get_all_plugin_configs.
|
||||
# @POST: Returns unique plugin EXECUTE permissions if loader is available.
|
||||
def _discover_plugin_execute_permissions(plugin_loader=None) -> Set[Tuple[str, str]]:
|
||||
with belief_scope("rbac_permission_catalog._discover_plugin_execute_permissions"):
|
||||
discovered: Set[Tuple[str, str]] = set()
|
||||
@@ -116,8 +112,8 @@ def _discover_plugin_execute_permissions(plugin_loader=None) -> Set[Tuple[str, s
|
||||
|
||||
# #region _discover_plugin_execute_permissions_cached [TYPE Function]
|
||||
# @BRIEF Cache dynamic plugin EXECUTE permission pairs by normalized plugin id tuple.
|
||||
# @PRE plugin_ids is a deterministic tuple of plugin ids.
|
||||
# @POST Returns stable permission tuple without repeated plugin catalog expansion.
|
||||
# @PRE: plugin_ids is a deterministic tuple of plugin ids.
|
||||
# @POST: Returns stable permission tuple without repeated plugin catalog expansion.
|
||||
@lru_cache(maxsize=8)
|
||||
def _discover_plugin_execute_permissions_cached(
|
||||
plugin_ids: Tuple[str, ...],
|
||||
@@ -129,9 +125,8 @@ def _discover_plugin_execute_permissions_cached(
|
||||
|
||||
# #region discover_declared_permissions [TYPE Function]
|
||||
# @BRIEF Builds canonical RBAC permission catalog from routes and plugin registry.
|
||||
# @PRE plugin_loader may be provided for dynamic task plugin permission discovery.
|
||||
# @POST Returns union of route-declared and dynamic plugin EXECUTE permissions.
|
||||
# @RETURN Set[Tuple[str, str]] - Complete discovered permission set.
|
||||
# @PRE: plugin_loader may be provided for dynamic task plugin permission discovery.
|
||||
# @POST: Returns union of route-declared and dynamic plugin EXECUTE permissions.
|
||||
def discover_declared_permissions(plugin_loader=None) -> Set[Tuple[str, str]]:
|
||||
with belief_scope("rbac_permission_catalog.discover_declared_permissions"):
|
||||
permissions = set(_discover_route_permissions_cached())
|
||||
@@ -151,11 +146,10 @@ def discover_declared_permissions(plugin_loader=None) -> Set[Tuple[str, str]]:
|
||||
|
||||
# #region sync_permission_catalog [TYPE Function]
|
||||
# @BRIEF Persists missing RBAC permission pairs into auth database.
|
||||
# @PRE db is a valid SQLAlchemy session bound to auth database.
|
||||
# @PRE declared_permissions is an iterable of (resource, action) tuples.
|
||||
# @POST Missing permissions are inserted; existing permissions remain untouched.
|
||||
# @SIDE_EFFECT Commits auth database transaction when new permissions are added.
|
||||
# @RETURN int - Number of inserted permission records.
|
||||
# @PRE: db is a valid SQLAlchemy session bound to auth database.
|
||||
# @PRE: declared_permissions is an iterable of (resource, action) tuples.
|
||||
# @POST: Missing permissions are inserted; existing permissions remain untouched.
|
||||
# @SIDE_EFFECT: Commits auth database transaction when new permissions are added.
|
||||
def sync_permission_catalog(
|
||||
db: Session,
|
||||
declared_permissions: Iterable[Tuple[str, str]],
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# #region test_report_normalizer [C:2] [TYPE Module] [SEMANTICS tests, reports, normalizer, fallback]
|
||||
# @BRIEF Validate unknown task type fallback and partial payload normalization behavior.
|
||||
# @LAYER Domain (Tests)
|
||||
# @INVARIANT Unknown plugin types are mapped to canonical unknown task type.
|
||||
# @RELATION TESTS -> [normalize_report:Function]
|
||||
# [DEF:test_report_normalizer:Module]
|
||||
# @COMPLEXITY: 2
|
||||
# @SEMANTICS: tests, reports, normalizer, fallback
|
||||
# @PURPOSE: Validate unknown task type fallback and partial payload normalization behavior.
|
||||
# @RELATION: TESTS ->[normalize_report:Function]
|
||||
# @LAYER: Domain (Tests)
|
||||
# @INVARIANT: Unknown plugin types are mapped to canonical unknown task type.
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
@@ -10,9 +12,9 @@ from src.core.task_manager.models import Task, TaskStatus
|
||||
from src.services.reports.normalizer import normalize_task_report
|
||||
|
||||
|
||||
# #region test_unknown_type_maps_to_unknown_profile [TYPE Function]
|
||||
# @BRIEF Ensure unknown plugin IDs map to unknown profile with populated summary and error context.
|
||||
# @RELATION BINDS_TO -> [test_report_normalizer]
|
||||
# [DEF:test_unknown_type_maps_to_unknown_profile:Function]
|
||||
# @RELATION: BINDS_TO -> test_report_normalizer
|
||||
# @PURPOSE: Ensure unknown plugin IDs map to unknown profile with populated summary and error context.
|
||||
def test_unknown_type_maps_to_unknown_profile():
|
||||
task = Task(
|
||||
id="unknown-1",
|
||||
@@ -31,12 +33,12 @@ def test_unknown_type_maps_to_unknown_profile():
|
||||
assert report.error_context is not None
|
||||
|
||||
|
||||
# #endregion test_unknown_type_maps_to_unknown_profile
|
||||
# [/DEF:test_unknown_type_maps_to_unknown_profile:Function]
|
||||
|
||||
|
||||
# #region test_partial_payload_keeps_report_visible_with_placeholders [TYPE Function]
|
||||
# @BRIEF Ensure missing result payload still yields visible report details with result placeholder.
|
||||
# @RELATION BINDS_TO -> [test_report_normalizer]
|
||||
# [DEF:test_partial_payload_keeps_report_visible_with_placeholders:Function]
|
||||
# @RELATION: BINDS_TO -> test_report_normalizer
|
||||
# @PURPOSE: Ensure missing result payload still yields visible report details with result placeholder.
|
||||
def test_partial_payload_keeps_report_visible_with_placeholders():
|
||||
task = Task(
|
||||
id="partial-1",
|
||||
@@ -55,12 +57,12 @@ def test_partial_payload_keeps_report_visible_with_placeholders():
|
||||
assert "result" in report.details
|
||||
|
||||
|
||||
# #endregion test_partial_payload_keeps_report_visible_with_placeholders
|
||||
# [/DEF:test_partial_payload_keeps_report_visible_with_placeholders:Function]
|
||||
|
||||
|
||||
# #region test_clean_release_plugin_maps_to_clean_release_task_type [TYPE Function]
|
||||
# @BRIEF Ensure clean-release plugin ID maps to clean_release task profile and summary passthrough.
|
||||
# @RELATION BINDS_TO -> [test_report_normalizer]
|
||||
# [DEF:test_clean_release_plugin_maps_to_clean_release_task_type:Function]
|
||||
# @RELATION: BINDS_TO -> test_report_normalizer
|
||||
# @PURPOSE: Ensure clean-release plugin ID maps to clean_release task profile and summary passthrough.
|
||||
def test_clean_release_plugin_maps_to_clean_release_task_type():
|
||||
task = Task(
|
||||
id="clean-release-1",
|
||||
@@ -78,5 +80,5 @@ def test_clean_release_plugin_maps_to_clean_release_task_type():
|
||||
assert report.summary == "Clean release compliance passed"
|
||||
|
||||
|
||||
# #endregion test_clean_release_plugin_maps_to_clean_release_task_type
|
||||
# #endregion test_report_normalizer
|
||||
# [/DEF:test_clean_release_plugin_maps_to_clean_release_task_type:Function]
|
||||
# [/DEF:test_report_normalizer:Module]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# #region test_report_service [C:2] [TYPE Module]
|
||||
# @BRIEF Unit tests for ReportsService list/detail operations
|
||||
# @LAYER Domain
|
||||
# @RELATION TESTS -> [ReportsService:Class]
|
||||
# [DEF:test_report_service:Module]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Unit tests for ReportsService list/detail operations
|
||||
# @RELATION: TESTS ->[ReportsService:Class]
|
||||
# @LAYER: Domain
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -12,8 +13,8 @@ from unittest.mock import MagicMock, patch
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
|
||||
# #region _make_task [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [test_report_service]
|
||||
# [DEF:_make_task:Function]
|
||||
# @RELATION: BINDS_TO -> test_report_service
|
||||
def _make_task(task_id="task-1", plugin_id="superset-backup", status_value="SUCCESS",
|
||||
started_at=None, finished_at=None, result=None, params=None, logs=None):
|
||||
"""Create a mock Task object matching the Task model interface."""
|
||||
@@ -29,7 +30,7 @@ def _make_task(task_id="task-1", plugin_id="superset-backup", status_value="SUCC
|
||||
return task
|
||||
|
||||
|
||||
# #endregion _make_task
|
||||
# [/DEF:_make_task:Function]
|
||||
|
||||
class TestReportsServiceList:
|
||||
"""Tests for ReportsService.list_reports."""
|
||||
@@ -183,4 +184,4 @@ class TestReportsServiceDetail:
|
||||
detail = svc.get_report_detail("ok-task")
|
||||
assert detail.next_actions == []
|
||||
|
||||
# #endregion test_report_service
|
||||
# [/DEF:test_report_service:Module]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# #region __tests__/test_report_type_profiles [TYPE Module]
|
||||
# @BRIEF Contract testing for task type profiles and resolution logic.
|
||||
# @RELATION VERIFIES -> [../type_profiles.py]
|
||||
# #endregion __tests__/test_report_type_profiles
|
||||
# [DEF:__tests__/test_report_type_profiles:Module]
|
||||
# @RELATION: VERIFIES -> ../type_profiles.py
|
||||
# @PURPOSE: Contract testing for task type profiles and resolution logic.
|
||||
# [/DEF:__tests__/test_report_type_profiles:Module]
|
||||
|
||||
import pytest
|
||||
from src.models.report import TaskType
|
||||
@@ -9,9 +9,9 @@ from src.services.reports.type_profiles import resolve_task_type, get_type_profi
|
||||
|
||||
# @TEST_CONTRACT: ResolveTaskType -> Invariants
|
||||
# @TEST_INVARIANT: fallback_to_unknown
|
||||
# #region test_resolve_task_type_fallbacks [TYPE Function]
|
||||
# @BRIEF Verify resolve_task_type_fallbacks returns correct fallback type when primary is missing.
|
||||
# @RELATION BINDS_TO -> [__tests__/test_report_type_profiles]
|
||||
# [DEF:test_resolve_task_type_fallbacks:Function]
|
||||
# @RELATION: BINDS_TO -> __tests__/test_report_type_profiles
|
||||
# @PURPOSE: Verify resolve_task_type_fallbacks returns correct fallback type when primary is missing.
|
||||
def test_resolve_task_type_fallbacks():
|
||||
"""Verify missing/unmapped plugin_id returns TaskType.UNKNOWN."""
|
||||
assert resolve_task_type(None) == TaskType.UNKNOWN
|
||||
@@ -20,11 +20,11 @@ def test_resolve_task_type_fallbacks():
|
||||
assert resolve_task_type("invalid_plugin") == TaskType.UNKNOWN
|
||||
|
||||
# @TEST_FIXTURE: valid_plugin
|
||||
# #endregion test_resolve_task_type_fallbacks
|
||||
# [/DEF:test_resolve_task_type_fallbacks:Function]
|
||||
|
||||
# #region test_resolve_task_type_valid [TYPE Function]
|
||||
# @BRIEF Verify resolve_task_type_valid returns the correct type when valid input is provided.
|
||||
# @RELATION BINDS_TO -> [__tests__/test_report_type_profiles]
|
||||
# [DEF:test_resolve_task_type_valid:Function]
|
||||
# @RELATION: BINDS_TO -> __tests__/test_report_type_profiles
|
||||
# @PURPOSE: Verify resolve_task_type_valid returns the correct type when valid input is provided.
|
||||
def test_resolve_task_type_valid():
|
||||
"""Verify known plugin IDs map correctly."""
|
||||
assert resolve_task_type("superset-migration") == TaskType.MIGRATION
|
||||
@@ -33,11 +33,11 @@ def test_resolve_task_type_valid():
|
||||
assert resolve_task_type("documentation") == TaskType.DOCUMENTATION
|
||||
|
||||
# @TEST_FIXTURE: valid_profile
|
||||
# #endregion test_resolve_task_type_valid
|
||||
# [/DEF:test_resolve_task_type_valid:Function]
|
||||
|
||||
# #region test_get_type_profile_valid [TYPE Function]
|
||||
# @BRIEF Verify get_type_profile_valid returns the correct profile for a valid task type.
|
||||
# @RELATION BINDS_TO -> [__tests__/test_report_type_profiles]
|
||||
# [DEF:test_get_type_profile_valid:Function]
|
||||
# @RELATION: BINDS_TO -> __tests__/test_report_type_profiles
|
||||
# @PURPOSE: Verify get_type_profile_valid returns the correct profile for a valid task type.
|
||||
def test_get_type_profile_valid():
|
||||
"""Verify known task types return correct profile metadata."""
|
||||
profile = get_type_profile(TaskType.MIGRATION)
|
||||
@@ -47,11 +47,11 @@ def test_get_type_profile_valid():
|
||||
|
||||
# @TEST_INVARIANT: always_returns_dict
|
||||
# @TEST_EDGE: missing_profile
|
||||
# #endregion test_get_type_profile_valid
|
||||
# [/DEF:test_get_type_profile_valid:Function]
|
||||
|
||||
# #region test_get_type_profile_fallback [TYPE Function]
|
||||
# @BRIEF Verify get_type_profile_fallback returns default profile when type is unknown.
|
||||
# @RELATION BINDS_TO -> [__tests__/test_report_type_profiles]
|
||||
# [DEF:test_get_type_profile_fallback:Function]
|
||||
# @RELATION: BINDS_TO -> __tests__/test_report_type_profiles
|
||||
# @PURPOSE: Verify get_type_profile_fallback returns default profile when type is unknown.
|
||||
def test_get_type_profile_fallback():
|
||||
"""Verify unknown task type returns fallback profile."""
|
||||
# Assuming TaskType.UNKNOWN or any non-mapped value
|
||||
@@ -63,4 +63,4 @@ def test_get_type_profile_fallback():
|
||||
profile_fallback = get_type_profile("non-enum-value")
|
||||
assert profile_fallback["display_label"] == "Other / Unknown"
|
||||
assert profile_fallback["fallback"] is True
|
||||
# #endregion test_get_type_profile_fallback
|
||||
# [/DEF:test_get_type_profile_fallback:Function]
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
# #region normalizer [C:5] [TYPE Module] [SEMANTICS reports, normalization, tasks, fallback]
|
||||
# @BRIEF Convert task manager task objects into canonical unified TaskReport entities with deterministic fallback behavior.
|
||||
# @LAYER Domain
|
||||
# @PRE session is active and valid
|
||||
# @POST Returns Normalizer output with normalized fields
|
||||
# @SIDE_EFFECT Read-only database operations
|
||||
# @DATA_CONTRACT ReportRow -> NormalizerInput; session_id -> valid UUID
|
||||
# @INVARIANT Normalizer instance maintains consistent field order
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [backend.src.core.task_manager.models.Task:Function]
|
||||
# @RELATION DEPENDS_ON -> [backend.src.models.report:Function]
|
||||
# @RELATION DEPENDS_ON -> [backend.src.services.reports.type_profiles:Function]
|
||||
# @INVARIANT: Normalizer instance maintains consistent field order
|
||||
# @DATA_CONTRACT: ReportRow -> NormalizerInput; session_id -> valid UUID
|
||||
# @PRE: session is active and valid
|
||||
# @POST: Returns Normalizer output with normalized fields
|
||||
# @SIDE_EFFECT: Read-only database operations
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
@@ -18,15 +17,12 @@ from ...core.logger import belief_scope
|
||||
from ...core.task_manager.models import Task, TaskStatus
|
||||
from ...models.report import ErrorContext, ReportStatus, TaskReport
|
||||
from .type_profiles import get_type_profile, resolve_task_type
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
# #region status_to_report_status [TYPE Function]
|
||||
# @BRIEF Normalize internal task status to canonical report status.
|
||||
# @PRE status may be known or unknown string/enum value.
|
||||
# @POST Always returns one of canonical ReportStatus values.
|
||||
# @PARAM: status (Any) - Internal task status value.
|
||||
# @RETURN: ReportStatus - Canonical report status.
|
||||
# @PRE: status may be known or unknown string/enum value.
|
||||
# @POST: Always returns one of canonical ReportStatus values.
|
||||
def status_to_report_status(status: Any) -> ReportStatus:
|
||||
with belief_scope("status_to_report_status"):
|
||||
raw = str(status.value if isinstance(status, TaskStatus) else status).upper()
|
||||
@@ -42,11 +38,8 @@ def status_to_report_status(status: Any) -> ReportStatus:
|
||||
|
||||
# #region build_summary [TYPE Function]
|
||||
# @BRIEF Build deterministic user-facing summary from task payload and status.
|
||||
# @PRE report_status is canonical; plugin_id may be unknown.
|
||||
# @POST Returns non-empty summary text.
|
||||
# @PARAM: task (Task) - Source task object.
|
||||
# @PARAM: report_status (ReportStatus) - Canonical status.
|
||||
# @RETURN: str - Normalized summary.
|
||||
# @PRE: report_status is canonical; plugin_id may be unknown.
|
||||
# @POST: Returns non-empty summary text.
|
||||
def build_summary(task: Task, report_status: ReportStatus) -> str:
|
||||
with belief_scope("build_summary"):
|
||||
result = task.result
|
||||
@@ -67,11 +60,8 @@ def build_summary(task: Task, report_status: ReportStatus) -> str:
|
||||
|
||||
# #region extract_error_context [TYPE Function]
|
||||
# @BRIEF Extract normalized error context and next actions for failed/partial reports.
|
||||
# @PRE task is a valid Task object.
|
||||
# @POST Returns ErrorContext for failed/partial when context exists; otherwise None.
|
||||
# @PARAM: task (Task) - Source task.
|
||||
# @PARAM: report_status (ReportStatus) - Canonical status.
|
||||
# @RETURN: Optional[ErrorContext] - Error context block.
|
||||
# @PRE: task is a valid Task object.
|
||||
# @POST: Returns ErrorContext for failed/partial when context exists; otherwise None.
|
||||
def extract_error_context(task: Task, report_status: ReportStatus) -> Optional[ErrorContext]:
|
||||
with belief_scope("extract_error_context"):
|
||||
if report_status not in {ReportStatus.FAILED, ReportStatus.PARTIAL}:
|
||||
@@ -111,10 +101,8 @@ def extract_error_context(task: Task, report_status: ReportStatus) -> Optional[E
|
||||
|
||||
# #region normalize_task_report [TYPE Function]
|
||||
# @BRIEF Convert one Task to canonical TaskReport envelope.
|
||||
# @PRE task has valid id and plugin_id fields.
|
||||
# @POST Returns TaskReport with required fields and deterministic fallback behavior.
|
||||
# @PARAM: task (Task) - Source task.
|
||||
# @RETURN: TaskReport - Canonical normalized report.
|
||||
# @PRE: task has valid id and plugin_id fields.
|
||||
# @POST: Returns TaskReport with required fields and deterministic fallback behavior.
|
||||
#
|
||||
# @TEST_CONTRACT: NormalizeTaskReport ->
|
||||
# {
|
||||
@@ -170,4 +158,4 @@ def normalize_task_report(task: Task) -> TaskReport:
|
||||
)
|
||||
# #endregion normalize_task_report
|
||||
|
||||
# #endregion normalizer
|
||||
# #endregion normalizer
|
||||
@@ -1,11 +1,6 @@
|
||||
# #region report_service [C:5] [TYPE Module] [SEMANTICS reports, service, aggregation, filtering, pagination, detail]
|
||||
# @BRIEF Aggregate, normalize, filter, and paginate task reports for unified list/detail API use cases.
|
||||
# @LAYER Domain
|
||||
# @PRE session is active and valid
|
||||
# @POST Returns Report with generated summary
|
||||
# @SIDE_EFFECT Read-only database operations; logs report generation
|
||||
# @DATA_CONTRACT ReportQuery -> ReportRow; session_id -> valid UUID
|
||||
# @INVARIANT ReportService maintains consistent report structure
|
||||
# @LAYER: Domain
|
||||
# @RELATION DEPENDS_ON -> [TaskManager]
|
||||
# @RELATION DEPENDS_ON -> [TaskReport]
|
||||
# @RELATION DEPENDS_ON -> [ReportQuery]
|
||||
@@ -13,8 +8,12 @@
|
||||
# @RELATION DEPENDS_ON -> [ReportDetailView]
|
||||
# @RELATION DEPENDS_ON -> [normalize_task_report]
|
||||
# @RELATION DEPENDS_ON -> [CleanReleaseRepository]
|
||||
# @INVARIANT: ReportService maintains consistent report structure
|
||||
# @DATA_CONTRACT: ReportQuery -> ReportRow; session_id -> valid UUID
|
||||
# @PRE: session is active and valid
|
||||
# @POST: Returns Report with generated summary
|
||||
# @SIDE_EFFECT: Read-only database operations; logs report generation
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
@@ -31,19 +30,18 @@ from ...models.report import (
|
||||
)
|
||||
from ..clean_release.repository import CleanReleaseRepository
|
||||
from .normalizer import normalize_task_report
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
# #region ReportsService [C:5] [TYPE Class]
|
||||
# @BRIEF Service layer for list/detail report retrieval and normalization.
|
||||
# @PRE TaskManager dependency is initialized.
|
||||
# @POST Provides deterministic list/detail report responses.
|
||||
# @SIDE_EFFECT Reads task history and optional clean-release repository state without mutating source records.
|
||||
# @DATA_CONTRACT Input[TaskManager, Optional[CleanReleaseRepository], ReportQuery|report_id] -> Output[ReportCollection|ReportDetailView|None]
|
||||
# @INVARIANT Service methods are read-only over task history source.
|
||||
# @PRE: TaskManager dependency is initialized.
|
||||
# @POST: Provides deterministic list/detail report responses.
|
||||
# @RELATION DEPENDS_ON -> [TaskManager]
|
||||
# @RELATION DEPENDS_ON -> [CleanReleaseRepository]
|
||||
# @RELATION CALLS -> [normalize_task_report]
|
||||
# @SIDE_EFFECT: Reads task history and optional clean-release repository state without mutating source records.
|
||||
# @DATA_CONTRACT: Input[TaskManager, Optional[CleanReleaseRepository], ReportQuery|report_id] -> Output[ReportCollection|ReportDetailView|None]
|
||||
# @INVARIANT: Service methods are read-only over task history source.
|
||||
#
|
||||
# @TEST_CONTRACT: ReportsServiceModel ->
|
||||
# {
|
||||
@@ -58,16 +56,17 @@ from .normalizer import normalize_task_report
|
||||
# @TEST_EDGE: report_not_found -> get_report_detail returns None
|
||||
# @TEST_INVARIANT: consistent_pagination -> verifies: [valid_service]
|
||||
class ReportsService:
|
||||
# #region init [C:5] [TYPE Function]
|
||||
# @BRIEF Initialize service with TaskManager dependency.
|
||||
# @PRE task_manager is a live TaskManager instance.
|
||||
# @POST self.task_manager is assigned and ready for read operations.
|
||||
# @SIDE_EFFECT Stores collaborator references for later read-only report projections.
|
||||
# @DATA_CONTRACT Input[TaskManager, Optional[CleanReleaseRepository]] -> Output[ReportsService]
|
||||
# @INVARIANT Constructor performs no task mutations.
|
||||
# @RELATION BINDS_TO -> [ReportsService]
|
||||
# @RELATION DEPENDS_ON -> [TaskManager]
|
||||
# @RELATION DEPENDS_ON -> [CleanReleaseRepository]
|
||||
# [DEF:init:Function]
|
||||
# @COMPLEXITY: 5
|
||||
# @PURPOSE: Initialize service with TaskManager dependency.
|
||||
# @PRE: task_manager is a live TaskManager instance.
|
||||
# @POST: self.task_manager is assigned and ready for read operations.
|
||||
# @INVARIANT: Constructor performs no task mutations.
|
||||
# @RELATION: [BINDS_TO] ->[ReportsService]
|
||||
# @RELATION: [DEPENDS_ON] ->[TaskManager]
|
||||
# @RELATION: [DEPENDS_ON] ->[CleanReleaseRepository]
|
||||
# @SIDE_EFFECT: Stores collaborator references for later read-only report projections.
|
||||
# @DATA_CONTRACT: Input[TaskManager, Optional[CleanReleaseRepository]] -> Output[ReportsService]
|
||||
# @PARAM: task_manager (TaskManager) - Task manager providing source task history.
|
||||
def __init__(
|
||||
self,
|
||||
@@ -78,27 +77,27 @@ class ReportsService:
|
||||
self.task_manager = task_manager
|
||||
self.clean_release_repository = clean_release_repository
|
||||
|
||||
# #endregion init
|
||||
# [/DEF:init:Function]
|
||||
|
||||
# #region _load_normalized_reports [TYPE Function]
|
||||
# @BRIEF Build normalized reports from all available tasks.
|
||||
# @PRE Task manager returns iterable task history records.
|
||||
# @POST Returns normalized report list preserving source cardinality.
|
||||
# @INVARIANT Every returned item is a TaskReport.
|
||||
# @RETURN List[TaskReport] - Reports sorted later by list logic.
|
||||
# [DEF:_load_normalized_reports:Function]
|
||||
# @PURPOSE: Build normalized reports from all available tasks.
|
||||
# @PRE: Task manager returns iterable task history records.
|
||||
# @POST: Returns normalized report list preserving source cardinality.
|
||||
# @INVARIANT: Every returned item is a TaskReport.
|
||||
# @RETURN: List[TaskReport] - Reports sorted later by list logic.
|
||||
def _load_normalized_reports(self) -> List[TaskReport]:
|
||||
with belief_scope("_load_normalized_reports"):
|
||||
tasks = self.task_manager.get_all_tasks()
|
||||
reports = [normalize_task_report(task) for task in tasks]
|
||||
return reports
|
||||
|
||||
# #endregion _load_normalized_reports
|
||||
# [/DEF:_load_normalized_reports:Function]
|
||||
|
||||
# #region _to_utc_datetime [TYPE Function]
|
||||
# @BRIEF Normalize naive/aware datetime values to UTC-aware datetime for safe comparisons.
|
||||
# @PRE value is either datetime or None.
|
||||
# @POST Returns UTC-aware datetime or None.
|
||||
# @INVARIANT Naive datetimes are interpreted as UTC to preserve deterministic ordering/filtering.
|
||||
# [DEF:_to_utc_datetime:Function]
|
||||
# @PURPOSE: Normalize naive/aware datetime values to UTC-aware datetime for safe comparisons.
|
||||
# @PRE: value is either datetime or None.
|
||||
# @POST: Returns UTC-aware datetime or None.
|
||||
# @INVARIANT: Naive datetimes are interpreted as UTC to preserve deterministic ordering/filtering.
|
||||
# @PARAM: value (Optional[datetime]) - Source datetime value.
|
||||
# @RETURN: Optional[datetime] - UTC-aware datetime or None.
|
||||
def _to_utc_datetime(self, value: Optional[datetime]) -> Optional[datetime]:
|
||||
@@ -109,13 +108,13 @@ class ReportsService:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
# #endregion _to_utc_datetime
|
||||
# [/DEF:_to_utc_datetime:Function]
|
||||
|
||||
# #region _datetime_sort_key [TYPE Function]
|
||||
# @BRIEF Produce stable numeric sort key for report timestamps.
|
||||
# @PRE report contains updated_at datetime.
|
||||
# @POST Returns float timestamp suitable for deterministic sorting.
|
||||
# @INVARIANT Mixed naive/aware datetimes never raise TypeError.
|
||||
# [DEF:_datetime_sort_key:Function]
|
||||
# @PURPOSE: Produce stable numeric sort key for report timestamps.
|
||||
# @PRE: report contains updated_at datetime.
|
||||
# @POST: Returns float timestamp suitable for deterministic sorting.
|
||||
# @INVARIANT: Mixed naive/aware datetimes never raise TypeError.
|
||||
# @PARAM: report (TaskReport) - Report item.
|
||||
# @RETURN: float - UTC timestamp key.
|
||||
def _datetime_sort_key(self, report: TaskReport) -> float:
|
||||
@@ -125,13 +124,13 @@ class ReportsService:
|
||||
return 0.0
|
||||
return updated.timestamp()
|
||||
|
||||
# #endregion _datetime_sort_key
|
||||
# [/DEF:_datetime_sort_key:Function]
|
||||
|
||||
# #region _matches_query [TYPE Function]
|
||||
# @BRIEF Apply query filtering to a report.
|
||||
# @PRE report and query are normalized schema instances.
|
||||
# @POST Returns True iff report satisfies all active query filters.
|
||||
# @INVARIANT Filter evaluation is side-effect free.
|
||||
# [DEF:_matches_query:Function]
|
||||
# @PURPOSE: Apply query filtering to a report.
|
||||
# @PRE: report and query are normalized schema instances.
|
||||
# @POST: Returns True iff report satisfies all active query filters.
|
||||
# @INVARIANT: Filter evaluation is side-effect free.
|
||||
# @PARAM: report (TaskReport) - Candidate report.
|
||||
# @PARAM: query (ReportQuery) - Applied query.
|
||||
# @RETURN: bool - True if report matches all filters.
|
||||
@@ -164,13 +163,13 @@ class ReportsService:
|
||||
return False
|
||||
return True
|
||||
|
||||
# #endregion _matches_query
|
||||
# [/DEF:_matches_query:Function]
|
||||
|
||||
# #region _sort_reports [TYPE Function]
|
||||
# @BRIEF Sort reports deterministically according to query settings.
|
||||
# @PRE reports contains only TaskReport items.
|
||||
# @POST Returns reports ordered by selected sort field and order.
|
||||
# @INVARIANT Sorting criteria are deterministic for equal input.
|
||||
# [DEF:_sort_reports:Function]
|
||||
# @PURPOSE: Sort reports deterministically according to query settings.
|
||||
# @PRE: reports contains only TaskReport items.
|
||||
# @POST: Returns reports ordered by selected sort field and order.
|
||||
# @INVARIANT: Sorting criteria are deterministic for equal input.
|
||||
# @PARAM: reports (List[TaskReport]) - Filtered reports.
|
||||
# @PARAM: query (ReportQuery) - Sort config.
|
||||
# @RETURN: List[TaskReport] - Sorted reports.
|
||||
@@ -189,12 +188,12 @@ class ReportsService:
|
||||
|
||||
return reports
|
||||
|
||||
# #endregion _sort_reports
|
||||
# [/DEF:_sort_reports:Function]
|
||||
|
||||
# #region list_reports [TYPE Function]
|
||||
# @BRIEF Return filtered, sorted, paginated report collection.
|
||||
# @PRE query has passed schema validation.
|
||||
# @POST Returns {items,total,page,page_size,has_next,applied_filters}.
|
||||
# [DEF:list_reports:Function]
|
||||
# @PURPOSE: Return filtered, sorted, paginated report collection.
|
||||
# @PRE: query has passed schema validation.
|
||||
# @POST: Returns {items,total,page,page_size,has_next,applied_filters}.
|
||||
# @PARAM: query (ReportQuery) - List filters and pagination.
|
||||
# @RETURN: ReportCollection - Paginated unified reports payload.
|
||||
def list_reports(self, query: ReportQuery) -> ReportCollection:
|
||||
@@ -220,12 +219,12 @@ class ReportsService:
|
||||
applied_filters=query,
|
||||
)
|
||||
|
||||
# #endregion list_reports
|
||||
# [/DEF:list_reports:Function]
|
||||
|
||||
# #region get_report_detail [TYPE Function]
|
||||
# @BRIEF Return one normalized report with timeline/diagnostics/next actions.
|
||||
# @PRE report_id exists in normalized report set.
|
||||
# @POST Returns normalized detail envelope with diagnostics and next actions where applicable.
|
||||
# [DEF:get_report_detail:Function]
|
||||
# @PURPOSE: Return one normalized report with timeline/diagnostics/next actions.
|
||||
# @PRE: report_id exists in normalized report set.
|
||||
# @POST: Returns normalized detail envelope with diagnostics and next actions where applicable.
|
||||
# @PARAM: report_id (str) - Stable report identifier.
|
||||
# @RETURN: Optional[ReportDetailView] - Detailed report or None if not found.
|
||||
def get_report_detail(self, report_id: str) -> Optional[ReportDetailView]:
|
||||
@@ -298,8 +297,9 @@ class ReportsService:
|
||||
next_actions=next_actions,
|
||||
)
|
||||
|
||||
# #endregion get_report_detail
|
||||
# [/DEF:get_report_detail:Function]
|
||||
|
||||
|
||||
# #endregion ReportsService
|
||||
|
||||
# #endregion report_service
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
# #region type_profiles [C:2] [TYPE Module]
|
||||
# @BRIEF Deterministic mapping of plugin/task identifiers to canonical report task types and fallback profile metadata.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ...core.logger import belief_scope
|
||||
from ...models.report import TaskType
|
||||
# [/SECTION]
|
||||
|
||||
# #region PLUGIN_TO_TASK_TYPE [TYPE Data]
|
||||
# @BRIEF Maps plugin identifiers to normalized report task types.
|
||||
@@ -71,10 +69,8 @@ TASK_TYPE_PROFILES: Dict[TaskType, Dict[str, Any]] = {
|
||||
|
||||
# #region resolve_task_type [TYPE Function]
|
||||
# @BRIEF Resolve canonical task type from plugin/task identifier with guaranteed fallback.
|
||||
# @PRE plugin_id may be None or unknown.
|
||||
# @POST Always returns one of TaskType enum values.
|
||||
# @PARAM: plugin_id (Optional[str]) - Source plugin/task identifier from task record.
|
||||
# @RETURN: TaskType - Resolved canonical type or UNKNOWN fallback.
|
||||
# @PRE: plugin_id may be None or unknown.
|
||||
# @POST: Always returns one of TaskType enum values.
|
||||
#
|
||||
# @TEST_CONTRACT: ResolveTaskType ->
|
||||
# {
|
||||
@@ -99,10 +95,8 @@ def resolve_task_type(plugin_id: Optional[str]) -> TaskType:
|
||||
|
||||
# #region get_type_profile [TYPE Function]
|
||||
# @BRIEF Return deterministic profile metadata for a task type.
|
||||
# @PRE task_type may be known or unknown.
|
||||
# @POST Returns a profile dict and never raises for unknown types.
|
||||
# @PARAM: task_type (TaskType) - Canonical task type.
|
||||
# @RETURN: Dict[str, Any] - Profile metadata used by normalization and UI contracts.
|
||||
# @PRE: task_type may be known or unknown.
|
||||
# @POST: Returns a profile dict and never raises for unknown types.
|
||||
#
|
||||
# @TEST_CONTRACT: GetTypeProfile ->
|
||||
# {
|
||||
|
||||
@@ -1,44 +1,41 @@
|
||||
# #region ResourceServiceModule [C:3] [TYPE Module] [SEMANTICS service, resources, dashboards, datasets, tasks, git]
|
||||
# @BRIEF Shared service for fetching resource data with Git status and task status
|
||||
# @LAYER Service
|
||||
# @INVARIANT All resources include metadata about their current state
|
||||
# @LAYER: Service
|
||||
# @RELATION DEPENDS_ON -> [SupersetClient]
|
||||
# @RELATION DEPENDS_ON -> [TaskManagerPackage]
|
||||
# @RELATION DEPENDS_ON -> [TaskManagerModels]
|
||||
# @RELATION DEPENDS_ON -> [GitService]
|
||||
# @INVARIANT: All resources include metadata about their current state
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import List, Dict, Optional, Any
|
||||
from datetime import datetime, timezone
|
||||
from ..core.superset_client import SupersetClient
|
||||
from ..core.task_manager.models import Task
|
||||
from ..services.git_service import GitService
|
||||
from ..core.cot_logger import MarkerLogger
|
||||
from ..core.logger import logger, belief_scope
|
||||
|
||||
log = MarkerLogger("ResourceService")
|
||||
# [/SECTION]
|
||||
|
||||
# #region ResourceService [C:3] [TYPE Class]
|
||||
# @BRIEF Provides centralized access to resource data with enhanced metadata
|
||||
# @RELATION DEPENDS_ON -> [SupersetClient]
|
||||
# @RELATION DEPENDS_ON -> [GitService]
|
||||
class ResourceService:
|
||||
|
||||
# #region ResourceService_init [C:1] [TYPE Function]
|
||||
# @BRIEF Initialize the resource service with dependencies
|
||||
# @PRE None
|
||||
# @POST ResourceService is ready to fetch resources
|
||||
# [DEF:ResourceService_init:Function]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: Initialize the resource service with dependencies
|
||||
# @PRE: None
|
||||
# @POST: ResourceService is ready to fetch resources
|
||||
def __init__(self):
|
||||
with belief_scope("ResourceService.__init__"):
|
||||
self.git_service = GitService()
|
||||
log.reason("Initialized ResourceService")
|
||||
# #endregion ResourceService_init
|
||||
logger.info("[ResourceService][Action] Initialized ResourceService")
|
||||
# [/DEF:ResourceService_init:Function]
|
||||
|
||||
# #region get_dashboards_with_status [C:3] [TYPE Function]
|
||||
# @BRIEF Fetch dashboards from environment with Git status and last task status
|
||||
# @PRE env is a valid Environment object
|
||||
# @POST Returns list of dashboards with enhanced metadata
|
||||
# [DEF:get_dashboards_with_status:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Fetch dashboards from environment with Git status and last task status
|
||||
# @PRE: env is a valid Environment object
|
||||
# @POST: Returns list of dashboards with enhanced metadata
|
||||
# @PARAM: env (Environment) - The environment to fetch from
|
||||
# @PARAM: tasks (List[Task]) - List of tasks to check for status
|
||||
# @RETURN: List[Dict] - Dashboards with git_status and last_task fields
|
||||
@@ -80,14 +77,15 @@ class ResourceService:
|
||||
|
||||
result.append(dashboard_dict)
|
||||
|
||||
log.reflect(f"Fetched {len(result)} dashboards with status")
|
||||
logger.info(f"[ResourceService][Coherence:OK] Fetched {len(result)} dashboards with status")
|
||||
return result
|
||||
# #endregion get_dashboards_with_status
|
||||
# [/DEF:get_dashboards_with_status:Function]
|
||||
|
||||
# #region get_dashboards_page_with_status [C:3] [TYPE Function]
|
||||
# @BRIEF Fetch one dashboard page from environment and enrich only that page with status metadata.
|
||||
# @PRE env is valid; page >= 1; page_size > 0.
|
||||
# @POST Returns page items plus total counters without scanning all pages locally.
|
||||
# [DEF:get_dashboards_page_with_status:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Fetch one dashboard page from environment and enrich only that page with status metadata.
|
||||
# @PRE: env is valid; page >= 1; page_size > 0.
|
||||
# @POST: Returns page items plus total counters without scanning all pages locally.
|
||||
# @PARAM: env (Environment) - Source environment.
|
||||
# @PARAM: tasks (Optional[List[Task]]) - Tasks for latest LLM status.
|
||||
# @PARAM: page (int) - 1-based page number.
|
||||
@@ -136,18 +134,25 @@ class ResourceService:
|
||||
result.append(dashboard_dict)
|
||||
|
||||
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
|
||||
log.reflect(f"Fetched dashboards page {page}/{total_pages} ({len(result)} items, total={total})")
|
||||
logger.info(
|
||||
"[ResourceService][Coherence:OK] Fetched dashboards page %s/%s (%s items, total=%s)",
|
||||
page,
|
||||
total_pages,
|
||||
len(result),
|
||||
total,
|
||||
)
|
||||
return {
|
||||
"dashboards": result,
|
||||
"total": total,
|
||||
"total_pages": total_pages,
|
||||
}
|
||||
# #endregion get_dashboards_page_with_status
|
||||
# [/DEF:get_dashboards_page_with_status:Function]
|
||||
|
||||
# #region _get_last_llm_task_for_dashboard [C:3] [TYPE Function]
|
||||
# @BRIEF Get most recent LLM validation task for a dashboard in an environment
|
||||
# @PRE dashboard_id is a valid integer identifier
|
||||
# @POST Returns the newest llm_dashboard_validation task summary or None
|
||||
# [DEF:_get_last_llm_task_for_dashboard:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Get most recent LLM validation task for a dashboard in an environment
|
||||
# @PRE: dashboard_id is a valid integer identifier
|
||||
# @POST: Returns the newest llm_dashboard_validation task summary or None
|
||||
# @PARAM: dashboard_id (int) - The dashboard ID
|
||||
# @PARAM: env_id (Optional[str]) - Environment ID to match task params
|
||||
# @PARAM: tasks (Optional[List[Task]]) - List of tasks to search
|
||||
@@ -225,12 +230,13 @@ class ResourceService:
|
||||
"status": self._normalize_task_status(getattr(latest_task, "status", "")),
|
||||
"validation_status": validation_status,
|
||||
}
|
||||
# #endregion _get_last_llm_task_for_dashboard
|
||||
# [/DEF:_get_last_llm_task_for_dashboard:Function]
|
||||
|
||||
# #region _normalize_task_status [C:3] [TYPE Function]
|
||||
# @BRIEF Normalize task status to stable uppercase values for UI/API projections
|
||||
# @PRE raw_status can be enum or string
|
||||
# @POST Returns uppercase status without enum class prefix
|
||||
# [DEF:_normalize_task_status:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Normalize task status to stable uppercase values for UI/API projections
|
||||
# @PRE: raw_status can be enum or string
|
||||
# @POST: Returns uppercase status without enum class prefix
|
||||
# @PARAM: raw_status (Any) - Raw task status object/value
|
||||
# @RETURN: str - Normalized status token
|
||||
# @RELATION: USED_BY ->[_get_last_llm_task_for_dashboard]
|
||||
@@ -242,12 +248,13 @@ class ResourceService:
|
||||
if "." in status_text:
|
||||
status_text = status_text.split(".")[-1]
|
||||
return status_text.upper()
|
||||
# #endregion _normalize_task_status
|
||||
# [/DEF:_normalize_task_status:Function]
|
||||
|
||||
# #region _normalize_validation_status [C:3] [TYPE Function]
|
||||
# @BRIEF Normalize LLM validation status to PASS/FAIL/WARN/UNKNOWN
|
||||
# @PRE raw_status can be any scalar type
|
||||
# @POST Returns normalized validation status token or None
|
||||
# [DEF:_normalize_validation_status:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Normalize LLM validation status to PASS/FAIL/WARN/UNKNOWN
|
||||
# @PRE: raw_status can be any scalar type
|
||||
# @POST: Returns normalized validation status token or None
|
||||
# @PARAM: raw_status (Any) - Raw validation status from task result
|
||||
# @RETURN: Optional[str] - PASS|FAIL|WARN|UNKNOWN
|
||||
# @RELATION: USED_BY ->[_get_last_llm_task_for_dashboard]
|
||||
@@ -258,12 +265,13 @@ class ResourceService:
|
||||
if status_text in {"PASS", "FAIL", "WARN"}:
|
||||
return status_text
|
||||
return "UNKNOWN"
|
||||
# #endregion _normalize_validation_status
|
||||
# [/DEF:_normalize_validation_status:Function]
|
||||
|
||||
# #region _normalize_datetime_for_compare [C:3] [TYPE Function]
|
||||
# @BRIEF Normalize datetime values to UTC-aware values for safe comparisons.
|
||||
# @PRE value may be datetime or any scalar.
|
||||
# @POST Returns UTC-aware datetime; non-datetime values map to minimal UTC datetime.
|
||||
# [DEF:_normalize_datetime_for_compare:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Normalize datetime values to UTC-aware values for safe comparisons.
|
||||
# @PRE: value may be datetime or any scalar.
|
||||
# @POST: Returns UTC-aware datetime; non-datetime values map to minimal UTC datetime.
|
||||
# @PARAM: value (Any) - Candidate datetime-like value.
|
||||
# @RETURN: datetime - UTC-aware comparable datetime.
|
||||
# @RELATION: USED_BY ->[_get_last_llm_task_for_dashboard]
|
||||
@@ -274,12 +282,13 @@ class ResourceService:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
return datetime.min.replace(tzinfo=timezone.utc)
|
||||
# #endregion _normalize_datetime_for_compare
|
||||
# [/DEF:_normalize_datetime_for_compare:Function]
|
||||
|
||||
# #region get_datasets_with_status [C:3] [TYPE Function]
|
||||
# @BRIEF Fetch datasets from environment with mapping progress and last task status
|
||||
# @PRE env is a valid Environment object
|
||||
# @POST Returns list of datasets with enhanced metadata
|
||||
# [DEF:get_datasets_with_status:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Fetch datasets from environment with mapping progress and last task status
|
||||
# @PRE: env is a valid Environment object
|
||||
# @POST: Returns list of datasets with enhanced metadata
|
||||
# @PARAM: env (Environment) - The environment to fetch from
|
||||
# @PARAM: tasks (List[Task]) - List of tasks to check for status
|
||||
# @RETURN: List[Dict] - Datasets with mapped_fields and last_task fields
|
||||
@@ -310,14 +319,15 @@ class ResourceService:
|
||||
|
||||
result.append(dataset_dict)
|
||||
|
||||
log.reflect(f"Fetched {len(result)} datasets with status")
|
||||
logger.info(f"[ResourceService][Coherence:OK] Fetched {len(result)} datasets with status")
|
||||
return result
|
||||
# #endregion get_datasets_with_status
|
||||
# [/DEF:get_datasets_with_status:Function]
|
||||
|
||||
# #region get_activity_summary [C:3] [TYPE Function]
|
||||
# @BRIEF Get summary of active and recent tasks for the activity indicator
|
||||
# @PRE tasks is a list of Task objects
|
||||
# @POST Returns summary with active_count and recent_tasks
|
||||
# [DEF:get_activity_summary:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Get summary of active and recent tasks for the activity indicator
|
||||
# @PRE: tasks is a list of Task objects
|
||||
# @POST: Returns summary with active_count and recent_tasks
|
||||
# @PARAM: tasks (List[Task]) - List of tasks to summarize
|
||||
# @RETURN: Dict - Activity summary
|
||||
# @RELATION: CALLS ->[_extract_resource_name_from_task]
|
||||
@@ -353,12 +363,13 @@ class ResourceService:
|
||||
'active_count': len(active_tasks),
|
||||
'recent_tasks': recent_tasks_formatted
|
||||
}
|
||||
# #endregion get_activity_summary
|
||||
# [/DEF:get_activity_summary:Function]
|
||||
|
||||
# #region _get_git_status_for_dashboard [C:3] [TYPE Function]
|
||||
# @BRIEF Get Git sync status for a dashboard
|
||||
# @PRE dashboard_id is a valid integer
|
||||
# @POST Returns git status or None if no repo exists
|
||||
# [DEF:_get_git_status_for_dashboard:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Get Git sync status for a dashboard
|
||||
# @PRE: dashboard_id is a valid integer
|
||||
# @POST: Returns git status or None if no repo exists
|
||||
# @PARAM: dashboard_id (int) - The dashboard ID
|
||||
# @RETURN: Optional[Dict] - Git status with branch and sync_status
|
||||
# @RELATION: CALLS ->[get_repo]
|
||||
@@ -397,7 +408,7 @@ class ResourceService:
|
||||
'has_changes_for_commit': has_changes_for_commit
|
||||
}
|
||||
except Exception:
|
||||
log.explore(f"Failed to get git status for dashboard {dashboard_id}", error="Git status check failed")
|
||||
logger.warning(f"[ResourceService][Warning] Failed to get git status for dashboard {dashboard_id}")
|
||||
return {
|
||||
'branch': None,
|
||||
'sync_status': 'ERROR',
|
||||
@@ -412,12 +423,13 @@ class ResourceService:
|
||||
'has_repo': False,
|
||||
'has_changes_for_commit': False
|
||||
}
|
||||
# #endregion _get_git_status_for_dashboard
|
||||
# [/DEF:_get_git_status_for_dashboard:Function]
|
||||
|
||||
# #region _get_last_task_for_resource [C:3] [TYPE Function]
|
||||
# @BRIEF Get the most recent task for a specific resource
|
||||
# @PRE resource_id is a valid string
|
||||
# @POST Returns task summary or None if no tasks found
|
||||
# [DEF:_get_last_task_for_resource:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Get the most recent task for a specific resource
|
||||
# @PRE: resource_id is a valid string
|
||||
# @POST: Returns task summary or None if no tasks found
|
||||
# @PARAM: resource_id (str) - The resource identifier (e.g., "dashboard-123")
|
||||
# @PARAM: tasks (Optional[List[Task]]) - List of tasks to search
|
||||
# @RETURN: Optional[Dict] - Task summary with task_id and status
|
||||
@@ -450,29 +462,32 @@ class ResourceService:
|
||||
'task_id': str(last_task.id),
|
||||
'status': last_task.status
|
||||
}
|
||||
# #endregion _get_last_task_for_resource
|
||||
# [/DEF:_get_last_task_for_resource:Function]
|
||||
|
||||
# #region _extract_resource_name_from_task [C:3] [TYPE Function]
|
||||
# @BRIEF Extract resource name from task params
|
||||
# @PRE task is a valid Task object
|
||||
# @POST Returns resource name or task ID
|
||||
# [DEF:_extract_resource_name_from_task:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Extract resource name from task params
|
||||
# @PRE: task is a valid Task object
|
||||
# @POST: Returns resource name or task ID
|
||||
# @PARAM: task (Task) - The task to extract from
|
||||
# @RETURN: str - Resource name or fallback
|
||||
# @RELATION: USED_BY ->[get_activity_summary]
|
||||
def _extract_resource_name_from_task(self, task: Task) -> str:
|
||||
params = task.params or {}
|
||||
return params.get('resource_name', f"Task {task.id}")
|
||||
# #endregion _extract_resource_name_from_task
|
||||
# [/DEF:_extract_resource_name_from_task:Function]
|
||||
|
||||
# #region _extract_resource_type_from_task [C:3] [TYPE Function]
|
||||
# @BRIEF Extract resource type from task params
|
||||
# @PRE task is a valid Task object
|
||||
# @POST Returns resource type or 'unknown'
|
||||
# [DEF:_extract_resource_type_from_task:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Extract resource type from task params
|
||||
# @PRE: task is a valid Task object
|
||||
# @POST: Returns resource type or 'unknown'
|
||||
# @PARAM: task (Task) - The task to extract from
|
||||
# @RETURN: str - Resource type
|
||||
# @RELATION: USED_BY ->[get_activity_summary]
|
||||
def _extract_resource_type_from_task(self, task: Task) -> str:
|
||||
params = task.params or {}
|
||||
return params.get('resource_type', 'unknown')
|
||||
# #endregion _extract_resource_type_from_task
|
||||
# [/DEF:_extract_resource_type_from_task:Function]
|
||||
# #endregion ResourceService
|
||||
# #endregion ResourceServiceModule
|
||||
|
||||
Reference in New Issue
Block a user