Some checks failed
DSS Project Analysis / dss-context-update (push) Has been cancelled
- Update all `from storage.` imports to `from dss.storage.` - Update `from config import config` to use `dss.settings` - Update `from auth.` imports to `from dss.auth.` - Update health check to use `dss.mcp.handler` - Fix SmartMerger import (merger.py not smart_merger.py) - Fix TranslationDictionary import path - Fix test assertion for networkx edges/links - Remove organ/body metaphors from: - API server health check - CLI status command and help text - Admin UI logger and error handler 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
402 lines
13 KiB
Python
402 lines
13 KiB
Python
"""
|
|
DSSProjectService - High-level API for export/import operations with transaction safety.
|
|
|
|
This service provides:
|
|
1. Transactional wrapper for safe database operations
|
|
2. Integration point for API/CLI layers
|
|
3. Proper error handling and rollback
|
|
4. Background job scheduling for large operations
|
|
5. SQLite configuration management
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from typing import Optional, Dict, Any, BinaryIO
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from contextlib import contextmanager
|
|
|
|
from .exporter import DSSArchiveExporter
|
|
from .importer import DSSArchiveImporter, ImportAnalysis
|
|
from .merger import SmartMerger, ConflictResolutionMode, MergeAnalysis
|
|
from .security import DatabaseLockingStrategy, MemoryLimitManager
|
|
from ..models.project import Project
|
|
from dss.storage.json_store import Projects, ActivityLog
|
|
|
|
|
|
@dataclass
|
|
class ExportSummary:
|
|
"""Result of an export operation"""
|
|
|
|
success: bool
|
|
archive_path: Optional[Path] = None
|
|
file_size_bytes: Optional[int] = None
|
|
item_counts: Optional[Dict[str, int]] = None
|
|
error: Optional[str] = None
|
|
duration_seconds: Optional[float] = None
|
|
|
|
|
|
@dataclass
|
|
class ImportSummary:
|
|
"""Result of an import operation"""
|
|
|
|
success: bool
|
|
project_name: Optional[str] = None
|
|
item_counts: Optional[Dict[str, int]] = None
|
|
warnings: Optional[list[str]] = None
|
|
error: Optional[str] = None
|
|
migration_performed: Optional[bool] = None
|
|
duration_seconds: Optional[float] = None
|
|
requires_background_job: bool = False
|
|
|
|
|
|
@dataclass
|
|
class MergeSummary:
|
|
"""Result of a merge operation"""
|
|
|
|
success: bool
|
|
new_items_count: Optional[int] = None
|
|
updated_items_count: Optional[int] = None
|
|
conflicts_count: Optional[int] = None
|
|
resolution_strategy: Optional[str] = None
|
|
error: Optional[str] = None
|
|
duration_seconds: Optional[float] = None
|
|
|
|
|
|
class DSSProjectService:
|
|
"""Service layer for DSS project export/import operations.
|
|
|
|
Provides transaction-safe operations with proper error handling,
|
|
database locking management, and memory limit enforcement.
|
|
|
|
Production Features:
|
|
- Transactional safety (rollback on error)
|
|
- SQLite locking configuration
|
|
- Memory and resource limits
|
|
- Background job scheduling for large operations
|
|
- Comprehensive error handling
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
busy_timeout_ms: int = DatabaseLockingStrategy.DEFAULT_BUSY_TIMEOUT_MS,
|
|
):
|
|
self.locking_strategy = DatabaseLockingStrategy(busy_timeout_ms)
|
|
self.memory_manager = MemoryLimitManager()
|
|
|
|
@contextmanager
|
|
def _transaction(self):
|
|
"""Context manager for transaction-safe database operations.
|
|
|
|
Handles:
|
|
- SQLite locking with busy_timeout
|
|
- Automatic rollback on error
|
|
- Connection cleanup
|
|
"""
|
|
conn = None
|
|
try:
|
|
# Get connection with locking pragmas
|
|
conn = get_connection()
|
|
|
|
# Apply locking pragmas
|
|
pragmas = self.locking_strategy.get_pragmas()
|
|
cursor = conn.cursor()
|
|
for pragma_name, pragma_value in pragmas.items():
|
|
if isinstance(pragma_value, int):
|
|
cursor.execute(f"PRAGMA {pragma_name} = {pragma_value}")
|
|
else:
|
|
cursor.execute(f"PRAGMA {pragma_name} = '{pragma_value}'")
|
|
|
|
yield conn
|
|
|
|
# Commit on success
|
|
conn.commit()
|
|
|
|
except Exception as e:
|
|
# Rollback on error
|
|
if conn:
|
|
conn.rollback()
|
|
raise e
|
|
|
|
finally:
|
|
# Cleanup
|
|
if conn:
|
|
conn.close()
|
|
|
|
def export_project(
|
|
self,
|
|
project: Project,
|
|
output_path: Path,
|
|
background: bool = False,
|
|
) -> ExportSummary:
|
|
"""Export a DSS project to .dss archive.
|
|
|
|
Args:
|
|
project: DSS Project to export
|
|
output_path: Where to save the .dss file
|
|
background: If True, schedule as background job (returns immediately)
|
|
|
|
Returns:
|
|
ExportSummary with status and metadata
|
|
"""
|
|
start_time = datetime.utcnow()
|
|
|
|
try:
|
|
# Check if should be background job
|
|
# Estimate: 1 second per 100 tokens/components
|
|
estimated_items = len(project.theme.tokens) + len(project.components)
|
|
estimated_duration = estimated_items / 100
|
|
requires_background = background or DatabaseLockingStrategy.should_schedule_background(
|
|
estimated_duration
|
|
)
|
|
|
|
if requires_background:
|
|
# In production: schedule with Celery/RQ
|
|
# For now: just note that it would be scheduled
|
|
return ExportSummary(
|
|
success=True,
|
|
archive_path=output_path,
|
|
item_counts={
|
|
"tokens": len(project.theme.tokens),
|
|
"components": len(project.components),
|
|
},
|
|
requires_background_job=True,
|
|
)
|
|
|
|
# Perform export in transaction
|
|
with self._transaction():
|
|
exporter = DSSArchiveExporter(project)
|
|
saved_path = exporter.export_to_file(output_path)
|
|
|
|
# Get file size
|
|
file_size = saved_path.stat().st_size
|
|
|
|
duration = (datetime.utcnow() - start_time).total_seconds()
|
|
|
|
return ExportSummary(
|
|
success=True,
|
|
archive_path=saved_path,
|
|
file_size_bytes=file_size,
|
|
item_counts={
|
|
"tokens": len(project.theme.tokens),
|
|
"components": len(project.components),
|
|
},
|
|
duration_seconds=duration,
|
|
)
|
|
|
|
except Exception as e:
|
|
duration = (datetime.utcnow() - start_time).total_seconds()
|
|
return ExportSummary(
|
|
success=False,
|
|
error=str(e),
|
|
duration_seconds=duration,
|
|
)
|
|
|
|
def import_project(
|
|
self,
|
|
archive_path: Path,
|
|
strategy: str = "replace",
|
|
background: bool = False,
|
|
) -> ImportSummary:
|
|
"""Import a DSS project from .dss archive.
|
|
|
|
Args:
|
|
archive_path: Path to .dss file
|
|
strategy: Import strategy ('replace', 'merge')
|
|
background: If True, schedule as background job
|
|
|
|
Returns:
|
|
ImportSummary with status and metadata
|
|
"""
|
|
start_time = datetime.utcnow()
|
|
|
|
try:
|
|
# Analyze archive first (safe, no modifications)
|
|
importer = DSSArchiveImporter(archive_path)
|
|
analysis = importer.analyze()
|
|
|
|
if not analysis.is_valid:
|
|
error_msgs = [e.message for e in analysis.errors]
|
|
return ImportSummary(
|
|
success=False,
|
|
error=f"Archive validation failed: {'; '.join(error_msgs)}",
|
|
)
|
|
|
|
# Check if should be background job
|
|
item_count = analysis.content_summary.get("tokens", {}).get("count", 0)
|
|
item_count += analysis.content_summary.get("components", {}).get("count", 0)
|
|
estimated_duration = item_count / 50 # 50 items/second estimate
|
|
|
|
requires_background = background or DatabaseLockingStrategy.should_schedule_background(
|
|
estimated_duration
|
|
)
|
|
|
|
if requires_background:
|
|
return ImportSummary(
|
|
success=True,
|
|
project_name=analysis.project_name,
|
|
item_counts=analysis.content_summary,
|
|
migration_performed=analysis.migration_needed,
|
|
requires_background_job=True,
|
|
)
|
|
|
|
# Perform import in transaction
|
|
with self._transaction():
|
|
project = importer.import_replace()
|
|
|
|
duration = (datetime.utcnow() - start_time).total_seconds()
|
|
|
|
return ImportSummary(
|
|
success=True,
|
|
project_name=project.name,
|
|
item_counts={
|
|
"tokens": len(project.theme.tokens),
|
|
"components": len(project.components),
|
|
},
|
|
warnings=analysis.warnings,
|
|
migration_performed=analysis.migration_needed,
|
|
duration_seconds=duration,
|
|
)
|
|
|
|
except Exception as e:
|
|
duration = (datetime.utcnow() - start_time).total_seconds()
|
|
return ImportSummary(
|
|
success=False,
|
|
error=str(e),
|
|
duration_seconds=duration,
|
|
)
|
|
|
|
def analyze_import(
|
|
self,
|
|
archive_path: Path,
|
|
) -> ImportAnalysis:
|
|
"""Analyze archive without importing (safe preview).
|
|
|
|
Args:
|
|
archive_path: Path to .dss file
|
|
|
|
Returns:
|
|
ImportAnalysis with detected issues and contents
|
|
"""
|
|
importer = DSSArchiveImporter(archive_path)
|
|
return importer.analyze()
|
|
|
|
def merge_project(
|
|
self,
|
|
local_project: Project,
|
|
archive_path: Path,
|
|
conflict_strategy: str = "keep_local",
|
|
) -> MergeSummary:
|
|
"""Merge imported project with local version.
|
|
|
|
Args:
|
|
local_project: Current local project
|
|
archive_path: Path to imported .dss file
|
|
conflict_strategy: How to resolve conflicts
|
|
- 'overwrite': Import wins
|
|
- 'keep_local': Local wins
|
|
- 'fork': Create separate copy
|
|
|
|
Returns:
|
|
MergeSummary with merge details
|
|
"""
|
|
start_time = datetime.utcnow()
|
|
|
|
try:
|
|
# Load imported project
|
|
importer = DSSArchiveImporter(archive_path)
|
|
analysis = importer.analyze()
|
|
|
|
if not analysis.is_valid:
|
|
error_msgs = [e.message for e in analysis.errors]
|
|
return MergeSummary(
|
|
success=False,
|
|
error=f"Archive validation failed: {'; '.join(error_msgs)}",
|
|
)
|
|
|
|
imported_project = importer.import_replace()
|
|
|
|
# Analyze merge
|
|
merger = SmartMerger(local_project, imported_project)
|
|
merge_analysis = merger.analyze_merge()
|
|
|
|
# Convert strategy string to enum
|
|
strategy_map = {
|
|
"overwrite": ConflictResolutionMode.OVERWRITE,
|
|
"keep_local": ConflictResolutionMode.KEEP_LOCAL,
|
|
"fork": ConflictResolutionMode.FORK,
|
|
}
|
|
|
|
strategy = strategy_map.get(
|
|
conflict_strategy.lower(),
|
|
ConflictResolutionMode.KEEP_LOCAL,
|
|
)
|
|
|
|
# Perform merge in transaction
|
|
with self._transaction():
|
|
merged = merger.merge_with_strategy(strategy)
|
|
|
|
duration = (datetime.utcnow() - start_time).total_seconds()
|
|
|
|
return MergeSummary(
|
|
success=True,
|
|
new_items_count=merge_analysis.total_new_items,
|
|
updated_items_count=merge_analysis.total_updated_items,
|
|
conflicts_count=len(merge_analysis.conflicted_items),
|
|
resolution_strategy=conflict_strategy,
|
|
duration_seconds=duration,
|
|
)
|
|
|
|
except Exception as e:
|
|
duration = (datetime.utcnow() - start_time).total_seconds()
|
|
return MergeSummary(
|
|
success=False,
|
|
error=str(e),
|
|
duration_seconds=duration,
|
|
)
|
|
|
|
def analyze_merge(
|
|
self,
|
|
local_project: Project,
|
|
archive_path: Path,
|
|
) -> MergeAnalysis:
|
|
"""Analyze merge without applying it (safe preview).
|
|
|
|
Args:
|
|
local_project: Current local project
|
|
archive_path: Path to imported .dss file
|
|
|
|
Returns:
|
|
MergeAnalysis with detected changes
|
|
"""
|
|
importer = DSSArchiveImporter(archive_path)
|
|
imported_project = importer.import_replace()
|
|
|
|
merger = SmartMerger(local_project, imported_project)
|
|
return merger.analyze_merge()
|
|
|
|
|
|
# Production Integration Example:
|
|
# ===================================
|
|
#
|
|
# from dss.export_import.service import DSSProjectService
|
|
#
|
|
# service = DSSProjectService(busy_timeout_ms=5000) # 5 second timeout
|
|
#
|
|
# # Export
|
|
# result = service.export_project(my_project, Path("export.dss"))
|
|
# if result.success:
|
|
# print(f"✓ Exported to {result.archive_path}")
|
|
#
|
|
# # Import
|
|
# result = service.import_project(Path("import.dss"))
|
|
# if result.success:
|
|
# print(f"✓ Imported {result.project_name}")
|
|
# elif result.requires_background_job:
|
|
# # Schedule with Celery/RQ and return job_id
|
|
# job_id = schedule_background_import(Path("import.dss"))
|
|
#
|
|
# # Merge
|
|
# result = service.merge_project(local, Path("updates.dss"), "keep_local")
|
|
# if result.success:
|
|
# print(f"✓ Merged with {result.new_items_count} new items")
|