""" Pipeline Factory - 工厂函数,负责初始化所有组件并创建 Pipeline 用户只需提供配置参数,工厂函数负责: 1. 初始化 Persistence 2. 初始化 DataSource(并注册 Handler) 3. 初始化 Collection 和 BindingManager 4. 初始化 Strategy(并注入依赖) 5. 创建 Pipeline(注入所有组件) 6. 返回完全就绪的 Pipeline """ from __future__ import annotations import asyncio import inspect import json import logging from pathlib import Path from typing import Any, Dict, List, Optional from ..common.binding import BindingManager from ..common.collection import DataCollection from ..common.persistence import PersistenceBackend, create_persistence_backend from ..common.registry import DomainRegistry from ..datasource import ensure_builtin_datasource_types_registered from ..datasource.type_registry import get_datasource_factory from ..config import ( PipelineRunConfig, MultiProjectRunConfig, DataSourceConfig, ApiDataSourceConfig, PersistConfig, JsonlDataSourceConfig, LoggingConfig, build_config, StrategyRuntimeConfig, build_config_from_file, build_multi_project_config_from_file, apply_config_overrides, OrphanAction, UpdateDirection, resolve_log_file_path, config_snapshot, is_multi_project_payload, load_overrides_from_file, ) from ..sync_system.strategy import BaseSyncStrategy from .run_logger import init_pipeline_logger from .full_sync_pipeline import FullSyncPipeline logger = logging.getLogger(__name__) def _project_root() -> Path: return Path(__file__).resolve().parents[2] def _prepare_remote_jsonl_dir(remote_dir: Path, *, project_root: Optional[Path] = None) -> Path: from ..datasource.jsonl.path_utils import prepare_remote_jsonl_dir return prepare_remote_jsonl_dir(remote_dir, project_root=(project_root or _project_root())) def _ensure_pipeline_logger( *, initialize_logger: bool, logger_file_path: Optional[str], logger_level: int, suppress_node_logs: bool, ) -> None: if not initialize_logger: return app_logger = logging.getLogger("sync_state_machine") if app_logger.handlers: return init_pipeline_logger( log_file_path=logger_file_path, level=logger_level, mirror_console=True, replace_handlers=False, suppress_node_logs=suppress_node_logs, ) def _build_handler_config( node_type: str, ds_config: DataSourceConfig, ) -> Dict[str, Any]: return dict(ds_config.handler_configs.get(node_type, {})) def _create_handler( *, node_type: str, ds_config: DataSourceConfig, datasource, ): datasource_type = ds_config.type handler_class = DomainRegistry.get_handler(node_type, datasource_type) if handler_class is None: raise RuntimeError( f"Missing {datasource_type.upper()} handler for node_type: {node_type}" ) handler_config = _build_handler_config(node_type, ds_config) if datasource_type == "api": handler = handler_class(**handler_config) else: handler = handler_class(datasource) if hasattr(handler, "set_handler_config"): handler.set_handler_config(handler_config) return handler async def _create_datasource( ds_config: DataSourceConfig, *, datasource_override=None, endpoint_name: str, project_root: Path | None = None, ): if datasource_override is not None: return datasource_override datasource_factory = get_datasource_factory(ds_config.type) if datasource_factory is None: raise RuntimeError(f"No datasource factory registered for type: {ds_config.type}") factory_kwargs: dict[str, Any] = {} signature = inspect.signature(datasource_factory) if "project_root" in signature.parameters: factory_kwargs["project_root"] = project_root or _project_root() datasource = datasource_factory(ds_config, endpoint_name, **factory_kwargs) if inspect.isawaitable(datasource): datasource = await datasource return datasource async def _initialize_datasource(datasource, *, endpoint_name: str) -> None: try: await datasource.initialize() except Exception as exc: raise RuntimeError(f"Failed to initialize {endpoint_name} datasource: {type(exc).__name__}: {exc}") from exc ensure_builtin_datasource_types_registered() def _add_handlers_for_endpoint( *, datasource, ds_config: DataSourceConfig, node_types: List[str], ) -> None: for node_type in node_types: datasource.add_handler( _create_handler( node_type=node_type, ds_config=ds_config, datasource=datasource, ) ) def _add_handlers( config: PipelineRunConfig, local_ds, remote_ds, all_types: List[str], ) -> None: _add_handlers_for_endpoint( datasource=local_ds, ds_config=config.local_datasource, node_types=all_types, ) _add_handlers_for_endpoint( datasource=remote_ds, ds_config=config.remote_datasource, node_types=all_types, ) def _build_collections_and_bindings( *, persistence: PersistenceBackend, scope: str, ) -> tuple[DataCollection, DataCollection, BindingManager]: local_collection = DataCollection("local", scope=scope, persistence=persistence, auto_persist=False) remote_collection = DataCollection("remote", scope=scope, persistence=persistence, auto_persist=False) binding_manager = BindingManager(persistence, auto_persist=False, scope=scope) return local_collection, remote_collection, binding_manager def _resolve_strategy_map(config: PipelineRunConfig, node_types: List[str]) -> Dict[str, StrategyRuntimeConfig]: if config.strategies: return {item.node_type: item for item in config.strategies} resolved: Dict[str, StrategyRuntimeConfig] = {} for node_type in node_types: strategy_class = DomainRegistry.get_strategy_class(node_type) if strategy_class is None: continue resolved[node_type] = StrategyRuntimeConfig( node_type=node_type, config=strategy_class.default_config.model_copy(deep=True), ) return resolved def _build_pipeline_run_config( *, node_types: List[str], local_datasource: DataSourceConfig, remote_datasource: DataSourceConfig, persist: PersistConfig, logging_config: LoggingConfig, strategy_overrides: Optional[Dict[str, Dict[str, Any]]] = None, ) -> PipelineRunConfig: cfg = PipelineRunConfig( node_types=node_types, local_datasource=local_datasource, remote_datasource=remote_datasource, strategies=[], persist=persist, logging=logging_config, ) if strategy_overrides: cfg = apply_config_overrides(cfg, {"strategies": strategy_overrides}) return cfg def _build_strategies( config: PipelineRunConfig, node_types: List[str], local_collection: DataCollection, remote_collection: DataCollection, binding_manager: BindingManager, strategy_map: Dict[str, StrategyRuntimeConfig], ) -> List[BaseSyncStrategy[Any]]: strategies: List[BaseSyncStrategy[Any]] = [] both_jsonl = isinstance(config.local_datasource, JsonlDataSourceConfig) and isinstance(config.remote_datasource, JsonlDataSourceConfig) for node_type in node_types: strategy_class = DomainRegistry.get_strategy_class(node_type) if strategy_class is None: raise RuntimeError(f"Missing strategy for node_type: {node_type}") strategy = strategy_class(node_type, local_collection, remote_collection, binding_manager) if both_jsonl: strategy.update_config( local_orphan_action=OrphanAction.CREATE_REMOTE, remote_orphan_action=OrphanAction.NONE, update_direction=UpdateDirection.PUSH, ) if node_type == "contract": strategy.update_config(depend_fields={"project_id": "project"}) runtime_cfg = strategy_map.get(node_type) if runtime_cfg is not None: strategy.set_config(runtime_cfg.config.model_copy(deep=True)) strategy.skip_sync = bool(runtime_cfg.skip_sync) strategies.append(strategy) return strategies async def create_pipeline_from_config( config: PipelineRunConfig, *, scope: str = "global", pipeline_name: str | None = None, bootstrap_binding_node_types: Optional[List[str]] = None, ephemeral_node_types: Optional[List[str]] = None, local_datasource_override=None, remote_datasource_override=None, ) -> FullSyncPipeline: if config.logging.initialize and not config.logging.file_path: config.logging.file_path = resolve_log_file_path(config.logging) _ensure_pipeline_logger( initialize_logger=config.logging.initialize, logger_file_path=config.logging.file_path, logger_level=config.logging.level, suppress_node_logs=config.logging.suppress_node_logs, ) all_types = list(config.node_types) strategy_map = _resolve_strategy_map(config, all_types) db_path = Path(config.persist.db_path) if config.persist.wipe_on_start and db_path.exists(): db_path.unlink() persistence: Optional[PersistenceBackend] = None local_ds = None remote_ds = None try: persistence = create_persistence_backend(config.persist) await persistence.initialize() local_ds = await _create_datasource( config.local_datasource, datasource_override=local_datasource_override, endpoint_name="local", project_root=_project_root(), ) await _initialize_datasource(local_ds, endpoint_name="local") remote_ds = await _create_datasource( config.remote_datasource, datasource_override=remote_datasource_override, endpoint_name="remote", project_root=_project_root(), ) await _initialize_datasource(remote_ds, endpoint_name="remote") _add_handlers(config, local_ds, remote_ds, all_types) local_collection, remote_collection, binding_manager = _build_collections_and_bindings( persistence=persistence, scope=scope, ) strategies = _build_strategies( config, all_types, local_collection, remote_collection, binding_manager, strategy_map, ) pipeline = FullSyncPipeline( local_datasource=local_ds, remote_datasource=remote_ds, strategies=strategies, node_types=all_types, persistence=persistence, local_collection=local_collection, remote_collection=remote_collection, binding_manager=binding_manager, scope=scope, pipeline_name=pipeline_name, bootstrap_binding_node_types=bootstrap_binding_node_types, ephemeral_node_types=ephemeral_node_types, enable_persistence=config.persist.enable, ) return pipeline except Exception: async def _safe_close(name: str, resource) -> None: if resource is None: return try: await asyncio.wait_for(resource.close(), timeout=3.0) except Exception as close_exc: logger.warning("Failed to close %s during create cleanup: %s", name, close_exc) await _safe_close("remote_datasource", remote_ds) await _safe_close("local_datasource", local_ds) await _safe_close("persistence", persistence) raise async def run_pipeline_from_config( config: PipelineRunConfig, *, title: str = "sync_state_machine pipeline", print_summary: bool = True, scope: str = "global", bootstrap_binding_node_types: Optional[List[str]] = None, local_datasource_override=None, remote_datasource_override=None, ) -> Dict[str, Any]: if config.logging.initialize and not config.logging.file_path: config.logging.file_path = resolve_log_file_path(config.logging) _ensure_pipeline_logger( initialize_logger=config.logging.initialize, logger_file_path=config.logging.file_path, logger_level=config.logging.level, suppress_node_logs=config.logging.suppress_node_logs, ) resolved_cfg = config_snapshot(config) resolved_cfg_text = json.dumps(resolved_cfg, ensure_ascii=False, indent=2) logger.info( "Resolved Full Config Summary: node_types=%d local=%s remote=%s", len(config.node_types), config.local_datasource.type, config.remote_datasource.type, ) if config.logging.file_path: try: with open(config.logging.file_path, "a", encoding="utf-8") as fp: fp.write("Resolved Full Config:\n") fp.write(resolved_cfg_text) fp.write("\n") except Exception as exc: logger.warning("Failed to write resolved config into log file: %s", exc) pipeline: Optional[FullSyncPipeline] = None try: if print_summary: print("\n" + "=" * 80) print(f"🚀 Creating {title}") print(f"📝 Log file: {config.logging.file_path or '-'}") print( f"🔧 Resolved Full Config Summary: node_types={len(config.node_types)} " f"local={config.local_datasource.type} " f"remote={config.remote_datasource.type}" ) print(f"🔧 Resolved Full Config:\n{resolved_cfg_text}") print("=" * 80) pipeline = await create_pipeline_from_config( config, scope=scope, pipeline_name=title, bootstrap_binding_node_types=bootstrap_binding_node_types, local_datasource_override=local_datasource_override, remote_datasource_override=remote_datasource_override, ) stats = await pipeline.run() if print_summary: print(f"\n✅ Pipeline completed: stats={stats}") print(f"🗃️ Persistence DB: {config.persist.db_path}") return stats finally: if pipeline is not None: await pipeline.close() async def create_pipeline_from_file( *, project_root: Path, config_path: str | Path, scope: str = "global", pipeline_name: str | None = None, bootstrap_binding_node_types: Optional[List[str]] = None, ephemeral_node_types: Optional[List[str]] = None, local_datasource_override=None, remote_datasource_override=None, ) -> FullSyncPipeline: config = build_config_from_file(project_root=project_root, file_path=config_path) return await create_pipeline_from_config( config, scope=scope, pipeline_name=pipeline_name, bootstrap_binding_node_types=bootstrap_binding_node_types, ephemeral_node_types=ephemeral_node_types, local_datasource_override=local_datasource_override, remote_datasource_override=remote_datasource_override, ) async def run_pipeline_from_file( *, project_root: Path, config_path: str | Path, title: str = "sync_state_machine pipeline", print_summary: bool = True, scope: str = "global", bootstrap_binding_node_types: Optional[List[str]] = None, local_datasource_override=None, remote_datasource_override=None, ) -> Dict[str, Any]: config = build_config_from_file(project_root=project_root, file_path=config_path) return await run_pipeline_from_config( config, title=title, print_summary=print_summary, scope=scope, bootstrap_binding_node_types=bootstrap_binding_node_types, local_datasource_override=local_datasource_override, remote_datasource_override=remote_datasource_override, ) async def run_profile_from_file( *, project_root: Path, config_path: str | Path, print_summary: bool = True, ) -> Dict[str, Any]: cfg_path = Path(config_path) raw = load_overrides_from_file(cfg_path, project_root) if is_multi_project_payload(raw): from .multi_project_pipeline import MultiProjectPipeline multi_cfg = MultiProjectRunConfig.model_validate(raw) pipeline = MultiProjectPipeline(project_root=project_root, config_path=cfg_path, config=multi_cfg) if print_summary: print("\n" + "=" * 80) print(f"🚀 Creating multi-project pipeline from: {cfg_path}") print("=" * 80) return await pipeline.run() config = build_config(project_root=project_root, overrides=raw) mode = f"{config.local_datasource.type}->{config.remote_datasource.type}" return await run_pipeline_from_config( config, title=f"sync_state_machine pipeline ({mode})", print_summary=print_summary, ) async def create_jsonl_to_api_pipeline( *, # 路径配置 local_jsonl_dir: str, persistence_db: str, # API 配置 api_base_url: str, api_uid: str, api_secret: str, # 节点类型 node_types: List[str], strategy_overrides: Optional[Dict[str, Dict[str, Any]]] = None, # Pipeline 行为 enable_persistence: bool = True, wipe_persistence_on_start: bool = False, # API 配置 api_debug: bool = False, poll_max_retries: int = 10, poll_interval: float = 0.5, api_rate_limit_max_requests: int = 30, api_rate_limit_window_seconds: float = 10.0, local_handler_configs: Optional[Dict[str, Dict[str, Any]]] = None, remote_handler_configs: Optional[Dict[str, Dict[str, Any]]] = None, # Logger 配置 initialize_logger: bool = True, logger_file_path: Optional[str] = None, logger_level: int = logging.INFO, ) -> FullSyncPipeline: cfg = _build_pipeline_run_config( node_types=node_types, local_datasource=JsonlDataSourceConfig( type="jsonl", jsonl_dir=local_jsonl_dir, read_only=False, handler_configs=local_handler_configs or {}, ), remote_datasource=ApiDataSourceConfig( type="api", api_base_url=api_base_url, api_uid=api_uid, api_secret=api_secret, api_debug=api_debug, poll_max_retries=poll_max_retries, poll_interval=poll_interval, api_rate_limit_max_requests=api_rate_limit_max_requests, api_rate_limit_window_seconds=api_rate_limit_window_seconds, handler_configs=remote_handler_configs or {}, ), persist=PersistConfig( db_path=persistence_db, enable=enable_persistence, wipe_on_start=wipe_persistence_on_start, ), logging_config=LoggingConfig( initialize=initialize_logger, file_path=logger_file_path, level=logger_level, ), strategy_overrides=strategy_overrides, ) return await create_pipeline_from_config(cfg) async def create_api_to_jsonl_pipeline( *, # API 配置(本地) api_base_url: str, api_uid: str, api_secret: str, # 路径配置 remote_jsonl_dir: str, persistence_db: str, # 节点类型 node_types: List[str], strategy_overrides: Optional[Dict[str, Dict[str, Any]]] = None, # Pipeline 行为 enable_persistence: bool = True, wipe_persistence_on_start: bool = False, # API 配置 api_debug: bool = False, poll_max_retries: int = 10, poll_interval: float = 0.5, api_rate_limit_max_requests: int = 30, api_rate_limit_window_seconds: float = 10.0, local_handler_configs: Optional[Dict[str, Dict[str, Any]]] = None, remote_handler_configs: Optional[Dict[str, Dict[str, Any]]] = None, # Logger 配置 initialize_logger: bool = True, logger_file_path: Optional[str] = None, logger_level: int = logging.INFO, ) -> FullSyncPipeline: cfg = _build_pipeline_run_config( node_types=node_types, local_datasource=ApiDataSourceConfig( type="api", api_base_url=api_base_url, api_uid=api_uid, api_secret=api_secret, api_debug=api_debug, poll_max_retries=poll_max_retries, poll_interval=poll_interval, api_rate_limit_max_requests=api_rate_limit_max_requests, api_rate_limit_window_seconds=api_rate_limit_window_seconds, handler_configs=local_handler_configs or {}, ), remote_datasource=JsonlDataSourceConfig( type="jsonl", jsonl_dir=remote_jsonl_dir, read_only=False, handler_configs=remote_handler_configs or {}, ), persist=PersistConfig( db_path=persistence_db, enable=enable_persistence, wipe_on_start=wipe_persistence_on_start, ), logging_config=LoggingConfig( initialize=initialize_logger, file_path=logger_file_path, level=logger_level, ), strategy_overrides=strategy_overrides, ) return await create_pipeline_from_config(cfg) async def create_jsonl_to_jsonl_pipeline( *, local_jsonl_dir: str, remote_jsonl_dir: str, persistence_db: str, node_types: List[str], enable_persistence: bool = True, wipe_persistence_on_start: bool = False, strategy_overrides: Optional[Dict[str, Dict[str, Any]]] = None, local_handler_configs: Optional[Dict[str, Dict[str, Any]]] = None, remote_handler_configs: Optional[Dict[str, Dict[str, Any]]] = None, initialize_logger: bool = True, logger_file_path: Optional[str] = None, logger_level: int = logging.INFO, ) -> FullSyncPipeline: cfg = _build_pipeline_run_config( node_types=node_types, local_datasource=JsonlDataSourceConfig( type="jsonl", jsonl_dir=local_jsonl_dir, read_only=False, handler_configs=local_handler_configs or {}, ), remote_datasource=JsonlDataSourceConfig( type="jsonl", jsonl_dir=remote_jsonl_dir, read_only=False, handler_configs=remote_handler_configs or {}, ), persist=PersistConfig( db_path=persistence_db, enable=enable_persistence, wipe_on_start=wipe_persistence_on_start, ), logging_config=LoggingConfig( initialize=initialize_logger, file_path=logger_file_path, level=logger_level, ), strategy_overrides=strategy_overrides, ) return await create_pipeline_from_config(cfg)