Source code for flexicon.sync.hierarchical_importer

"""
Hierarchical Importer - Phase 3.3

Import FLEx objects with full dependency resolution and hierarchical cascade.

Author: FlexTools Development Team
Date: 2025-11-27
"""

import logging
from typing import Any, List, Optional, Callable
from datetime import datetime

from .dependency_graph import DependencyGraph, CircularDependencyError
from .dependency_resolver import DependencyResolver, DependencyConfig
from .validation import LinguisticValidator, ValidationError, ValidationResult
from .merge_ops import MergeOperations, SyncError
from .engine import SyncResult

logger = logging.getLogger(__name__)


[docs] class HierarchicalImporter: """ Import FLEx objects with dependency resolution. Automatically discovers and imports all required dependencies, including owned objects (senses, examples) and referenced objects (POS, semantic domains). """ def __init__(self, source_project: Any, target_project: Any): """ Initialize hierarchical importer. Args: source_project: Source FlexProject target_project: Target FlexProject (must be writeEnabled) """ self.source_project = source_project self.target_project = target_project # Check write access if hasattr(target_project, "writeEnabled") and not target_project.writeEnabled: raise RuntimeError("Target project must be opened with writeEnabled=True for import operations") # Initialize components self.resolver = DependencyResolver(source_project, target_project) self.validator = LinguisticValidator(target_project) self.merger = MergeOperations(source_project, target_project)
[docs] def import_with_dependencies( self, object_type: str, guids: List[str], config: Optional[DependencyConfig] = None, validate_references: bool = True, progress_callback: Optional[Callable[[str], None]] = None, dry_run: bool = False, ) -> SyncResult: """ Import objects with full dependency trees. Discovers all dependencies (owned objects, referenced objects) and imports them in correct order. Args: object_type: Type of root objects to import guids: List of GUIDs to import config: Dependency resolution configuration validate_references: Validate all objects before import progress_callback: Optional callback for progress updates dry_run: If True, preview without actually importing Returns: SyncResult with import details Raises: CircularDependencyError: If circular dependency detected ValidationError: If validation fails with critical issues """ result = SyncResult(object_type) if config is None: config = DependencyConfig() try: if progress_callback: progress_callback(f"Resolving dependencies for {len(guids)} {object_type} object(s)...") # Build dependency graph graph = DependencyGraph() for guid in guids: # Get object from source obj = self._get_operations(self.source_project, object_type).Object(guid) if obj is None: logger.warning(f"Object {guid} not found in source project") result.add_error( SyncError( operation="resolve", object_guid=guid, error_message=f"{object_type} not found in source project", ) ) continue # Resolve dependencies obj_graph = self.resolver.resolve_dependencies(obj, object_type, config) # Merge into main graph self._merge_graphs(graph, obj_graph) if progress_callback: progress_callback(f"Dependency graph: {len(graph)} objects") logger.info(graph.summary()) # Detect cycles cycles = graph.detect_cycles() if cycles and not config.allow_cycles: raise CircularDependencyError(cycles[0]) # Get import order try: import_order = graph.get_import_order() except CircularDependencyError: # Try to break cycles at weak links (cross-references) if config.allow_cycles: import_order = self._break_cycles_and_sort(graph) else: raise if progress_callback: progress_callback(f"Import order determined: {len(import_order)} objects") # Validate all objects if validate_references and config.validate_all: validation_issues = self._validate_all(graph, import_order, progress_callback) if validation_issues and not dry_run: # Consolidate validation results consolidated = ValidationResult() for validation in validation_issues: for issue in validation.issues: consolidated.add_issue(issue) raise ValidationError(consolidated) # Import objects in order imported_count = self._import_objects( graph=graph, import_order=import_order, result=result, progress_callback=progress_callback, dry_run=dry_run, ) if progress_callback: if dry_run: progress_callback(f"Dry run complete: Would import {imported_count} objects") else: progress_callback(f"Import complete: {imported_count} objects created") except ValidationError: # Re-raise validation errors raise except CircularDependencyError: # Re-raise circular dependency errors raise except Exception as e: logger.error(f"Import failed: {e}") result.add_error(SyncError(operation="import", object_guid=None, error_message=str(e))) return result
def _import_objects( self, graph: DependencyGraph, import_order: List[tuple], result: SyncResult, progress_callback: Optional[Callable[[str], None]], dry_run: bool, ) -> int: """Import objects in dependency order.""" imported_count = 0 total = len(import_order) for idx, (guid, obj_type) in enumerate(import_order): # Check if already exists if self._exists_in_target(guid): logger.debug(f"Object {guid} already exists, skipping") continue # Get object node = graph.nodes.get(guid) if node is None or node.obj is None: # Object not available (might be a reference that exists in target) continue # Progress update if progress_callback and (idx % 10 == 0 or idx == total - 1): progress_callback(f"Processing {idx + 1}/{total}: {obj_type}") if dry_run: # Just count result.skip() imported_count += 1 else: # Actually import try: target_ops = self._get_operations(self.target_project, obj_type) source_ops = self._get_operations(self.source_project, obj_type) created_obj = self.merger.create_object( target_ops=target_ops, source_obj=node.obj, source_ops=source_ops ) if created_obj: from .merge_ops import SyncChange result.add_change(SyncChange(operation="create", object_type=obj_type, object_guid=guid)) imported_count += 1 else: result.add_error( SyncError(operation="create", object_guid=guid, error_message="create_object returned None") ) except Exception as e: logger.error(f"Failed to import {guid}: {e}") result.add_error(SyncError(operation="create", object_guid=guid, error_message=str(e))) return imported_count def _validate_all( self, graph: DependencyGraph, import_order: List[tuple], progress_callback: Optional[Callable[[str], None]] ) -> List[ValidationResult]: """Validate all objects in import order.""" validation_issues = [] for idx, (guid, obj_type) in enumerate(import_order): node = graph.nodes.get(guid) if node is None or node.obj is None: continue # Validate validation = self.validator.validate_before_create(node.obj, self.source_project, obj_type) if validation.has_critical: validation_issues.append(validation) logger.warning(f"Validation failed for {guid}:") logger.warning(validation.detailed_report()) if validation_issues and progress_callback: progress_callback(f"Validation found {len(validation_issues)} objects with critical issues") return validation_issues def _merge_graphs(self, target: DependencyGraph, source: DependencyGraph): """Merge source graph into target graph.""" # Add all nodes for guid, node in source.nodes.items(): if guid not in target: target.add_object(guid, node.object_type, node.obj) # Add all dependencies for guid, node in source.nodes.items(): for dep_guid in node.dependencies: dep_type = node.dependency_types[dep_guid] target.add_dependency(guid, dep_guid, dep_type) def _break_cycles_and_sort(self, graph: DependencyGraph) -> List[tuple]: """Break cycles at weakest links and return topological sort.""" from .dependency_graph import DependencyType # Find all cycles cycles = graph.detect_cycles() while cycles: # Break the first cycle at a CROSS_REFERENCE link (weakest) cycle = cycles[0] # Find a cross-reference link to break broken = False for i in range(len(cycle) - 1): from_guid = cycle[i] to_guid = cycle[i + 1] if from_guid in graph.nodes: node = graph.nodes[from_guid] if to_guid in node.dependency_types: dep_type = node.dependency_types[to_guid] if dep_type == DependencyType.CROSS_REFERENCE: # Break this link graph.remove_dependency(from_guid, to_guid) logger.info(f"Broke cycle at {from_guid}{to_guid}") broken = True break if not broken: # No cross-reference found, break at any REFERENCE for i in range(len(cycle) - 1): from_guid = cycle[i] to_guid = cycle[i + 1] if from_guid in graph.nodes: node = graph.nodes[from_guid] if to_guid in node.dependency_types: dep_type = node.dependency_types[to_guid] if dep_type == DependencyType.REFERENCE: graph.remove_dependency(from_guid, to_guid) logger.info(f"Broke cycle at {from_guid}{to_guid}") broken = True break if not broken: # Last resort: break first link graph.remove_dependency(cycle[0], cycle[1]) logger.warning(f"Broke cycle at ownership link {cycle[0]}{cycle[1]}") # Re-detect cycles cycles = graph.detect_cycles() # Now get topological sort return graph.get_import_order() def _find_referring_objects(self, guid: str, object_type: str) -> List[Any]: """Find all objects of given type that reference the specified object.""" referring = [] try: ops = self._get_operations(self.source_project, object_type) all_objects = ops.GetAll() for obj in all_objects: # Check if this object references the target GUID if self._object_references(obj, guid, object_type): referring.append(obj) except Exception as e: logger.error(f"Error finding referring objects: {e}") return referring def _object_references(self, obj: Any, target_guid: str, object_type: str) -> bool: """Check if object references target GUID.""" # Get all referenced objects referenced = self.resolver.get_referenced_objects(obj, object_type) for ref_obj, ref_type in referenced: ref_guid = str(ref_obj.Guid) if ref_guid == target_guid: return True return False def _get_operations(self, project: Any, object_type: str) -> Any: """Get operations class for object type.""" if not hasattr(project, object_type): raise AttributeError(f"Project does not have operations for {object_type}") return getattr(project, object_type) def _exists_in_target(self, guid: str) -> bool: """Check if object exists in target project.""" try: obj = self.target_project.Object(guid) return obj is not None except Exception: return False