Files
dss/dss/export_import/service.py
Bruno Sarlo faa19beef3
Some checks failed
DSS Project Analysis / dss-context-update (push) Has been cancelled
Fix import paths and remove organ metaphors
- 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>
2025-12-10 13:05:00 -03:00

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