Coverage for src/dataknobs_fsm/config/builder.py: 10%

1"""FSM builder for constructing FSM instances from configuration.

2

3 This module provides the FSMBuilder class that constructs executable FSM

4 instances from configuration objects, including:

5- Resource registration and initialization

6- Function resolution and registration

7- Network and state construction

8- Validation of completeness

9"""

10

11 import importlib

12 from typing import Any, Callable, Dict, List, Type

13

14

15 from dataknobs_fsm.config.schema import (

16 ArcConfig,

17 FSMConfig,

18 FunctionReference,

19 NetworkConfig,

20 PushArcConfig,

21 ResourceConfig,

22 ResourceType,

23 StateConfig,

24)

25 from dataknobs_fsm.core.arc import ArcDefinition, PushArc

26 from dataknobs_fsm.core.data_modes import DataHandler, DataHandlingMode, get_data_handler

27 from dataknobs_fsm.core.network import StateNetwork

28 from dataknobs_fsm.core.state import StateDefinition, StateType

29 from dataknobs_fsm.core.transactions import (

30 TransactionManager,

31 TransactionStrategy,

32 SingleTransactionManager,

33 BatchTransactionManager,

34 ManualTransactionManager,

35)

36 from dataknobs_fsm.core.fsm import FSM as CoreFSMClass # noqa: N811

37 from dataknobs_fsm.execution.context import ExecutionContext

38 from dataknobs_fsm.functions.base import (

39 IResource,

40 IStateTestFunction,

41 ITransformFunction,

42 IValidationFunction,

43)

44 from dataknobs_fsm.resources.manager import ResourceManager

45 from dataknobs_fsm.functions.manager import (

46 FunctionManager,

47 FunctionSource

48)

49

50

51 class FSMBuilder:

52 """Build executable FSM instances from configuration."""

53

54 def __init__(self):

55 """Initialize the FSMBuilder."""

56 self._resource_manager = ResourceManager()

57 self._function_manager = FunctionManager()

58 self._networks: Dict[str, StateNetwork] = {}

59 self._data_handlers: Dict[DataHandlingMode, DataHandler] = {}

60 self._transaction_manager: TransactionManager | None = None

61

62 # Register built-in functions on initialization

63 self._register_builtin_functions()

64

65 def build(self, config: FSMConfig) -> CoreFSMClass:

66 """Build an FSM instance from configuration.

67

68 Args:

69 config: FSM configuration.

70

71 Returns:

72 Executable FSM instance.

73

74 Raises:

75 ValueError: If configuration is invalid or incomplete.

76 """

77 # Clear previous build state

78 self._networks.clear()

79

80 # 1. Register resources

81 self._register_resources(config.resources)

82

83 # 2. Initialize data handlers

84 self._init_data_handlers(config.data_mode)

85

86 # 3. Initialize transaction manager

87 self._init_transaction_manager(config.transaction)

88

89 # 4. Build networks

90 for network_config in config.networks:

91 network = self._build_network(network_config, config)

92 self._networks[network.name] = network

93

94 # 5. Validate completeness

95 self._validate_completeness(config)

96

97 # 6. Create core FSM instance

98 from dataknobs_fsm.core.modes import ProcessingMode as CoreDataMode

99 from dataknobs_fsm.core.modes import TransactionMode as CoreTransactionMode

100

101 # Map config modes to core modes

102 data_mode = CoreDataMode.SINGLE # Default to SINGLE for now

103 transaction_mode = CoreTransactionMode.NONE # Default to NONE

104

105 fsm = CoreFSMClass(

106 name=config.name,

107 data_mode=data_mode,

108 transaction_mode=transaction_mode,

109 description=config.description,

110 resource_manager=self._resource_manager,

111 transaction_manager=self._transaction_manager,

112 )

113

114 # Store config in FSM for reference

115 fsm.config = config

116

117 # Register all functions from builder into core FSM's function registry

118 for func_name in self._function_manager.list_functions():

119 wrapper = self._function_manager.get_function(func_name)

120 if wrapper:

121 # The FSM's function registry expects callable functions

122 # If it's a FunctionWrapper, get the actual function

123 if hasattr(wrapper, 'func'):

124 fsm.function_registry.register(func_name, wrapper.func)

125 else:

126 fsm.function_registry.register(func_name, wrapper)

127

128 # Add networks to FSM

129 for network_name, network in self._networks.items():

130 fsm.add_network(network, is_main=(network_name == config.main_network))

131

132 # Return the core FSM directly

133 return fsm

134

135 def register_function(self, name: str, func: Callable) -> None:

136 """Register a custom function.

137

138 Args:

139 name: Function name for reference in configuration.

140 func: Function implementation.

141 """

142 self._function_manager.register_function(name, func, FunctionSource.REGISTERED)

143

144 def _register_builtin_functions(self) -> None:

145 """Register built-in functions from the library."""

146 # Import built-in function modules

147 try:

148 from dataknobs_fsm.functions.library import validators, transformers

149

150 # Register validators

151 for name in dir(validators):

152 if not name.startswith("_"):

153 obj = getattr(validators, name)

154 if callable(obj):

155 self._function_manager.register_function(

156 f"validators.{name}", obj, FunctionSource.BUILTIN

157 )

158

159 # Register transformers

160 for name in dir(transformers):

161 if not name.startswith("_"):

162 obj = getattr(transformers, name)

163 if callable(obj):

164 self._function_manager.register_function(

165 f"transformers.{name}", obj, FunctionSource.BUILTIN

166 )

167

168 except ImportError:

169 # Built-in functions not yet implemented

170 pass

171

172 def _register_resources(self, resources: List[ResourceConfig]) -> None:

173 """Register resources with the resource manager.

174

175 Args:

176 resources: Resource configurations.

177 """

178 for resource_config in resources:

179 resource = self._create_resource(resource_config)

180 self._resource_manager.register_provider(resource_config.name, resource)

181

182 def _create_resource(self, config: ResourceConfig) -> IResource:

183 """Create a resource instance from configuration.

184

185 Args:

186 config: Resource configuration.

187

188 Returns:

189 Resource instance.

190

191 Raises:

192 ValueError: If resource type is not supported.

193 """

194 # Map resource types to classes

195 resource_classes = {

196 "database": "dataknobs_fsm.resources.database.DatabaseResourceAdapter",

197 "filesystem": "dataknobs_fsm.resources.filesystem.FileSystemResource",

198 "http": "dataknobs_fsm.resources.http.HTTPServiceResource",

199 "llm": "dataknobs_fsm.resources.llm.LLMResource",

200 "vector_store": "dataknobs_fsm.resources.vector_store.VectorStoreResource",

201 }

202

203 if config.type == "custom":

204 # Custom resource must be in configuration

205 if "class" not in config.config:

206 raise ValueError("Custom resource requires 'class' in configuration")

207 class_path = config.config["class"]

208 else:

209 class_path = resource_classes.get(config.type)

210 if not class_path:

211 raise ValueError(f"Unsupported resource type: {config.type}")

212

213 # Import and instantiate resource class

214 module_path, class_name = class_path.rsplit(".", 1)

215 module = importlib.import_module(module_path)

216 resource_class = getattr(module, class_name)

217

218 # Create resource with configuration, adding name if needed

219 kwargs = config.config.copy()

220 if hasattr(resource_class, "__init__") and "name" in resource_class.__init__.__code__.co_varnames:

221 kwargs["name"] = config.name

222 return resource_class(**kwargs)

223

224 def _init_data_handlers(self, config: Any) -> None:

225 """Initialize data handlers for each mode.

226

227 Args:

228 config: Data mode configuration.

229 """

230 for mode in DataHandlingMode:

231 self._data_handlers[mode] = get_data_handler(mode)

232

233 def _init_transaction_manager(self, config: Any) -> None:

234 """Initialize transaction manager.

235

236 Args:

237 config: Transaction configuration.

238 """

239 if config.strategy == TransactionStrategy.SINGLE:

240 self._transaction_manager = SingleTransactionManager()

241 elif config.strategy == TransactionStrategy.BATCH:

242 self._transaction_manager = BatchTransactionManager(

243 batch_size=config.batch_size

244 )

245 elif config.strategy == TransactionStrategy.MANUAL:

246 self._transaction_manager = ManualTransactionManager()

247 else:

248 # Default to single transaction

249 self._transaction_manager = SingleTransactionManager()

250

251 def _build_network(self, network_config: NetworkConfig, fsm_config: FSMConfig) -> StateNetwork:

252 """Build a state network from configuration.

253

254 Args:

255 network_config: Network configuration.

256 fsm_config: Parent FSM configuration.

257

258 Returns:

259 StateNetwork instance.

260 """

261 network = StateNetwork(

262 name=network_config.name,

263 description=network_config.metadata.get("description", "") if network_config.metadata else None,

264 )

265

266 # Create states

267 state_defs = {}

268 for state_config in network_config.states:

269 state_def = self._build_state(state_config, fsm_config)

270 state_defs[state_def.name] = state_def

271 # Pass initial and final flags based on state type

272 from dataknobs_fsm.core.state import StateType

273 network.add_state(

274 state_def,

275 initial=(state_def.type in [StateType.START, StateType.START_END]),

276 final=(state_def.type in [StateType.END, StateType.START_END])

277 )

278

279 # Create arcs with definition order tracking

280 arc_definition_order = 0

281 for state_config in network_config.states:

282 state_def = state_defs[state_config.name]

283 for arc_config in state_config.arcs:

284 arc = self._build_arc(arc_config, state_def, network, fsm_config, arc_definition_order)

285 arc_definition_order += 1

286 # Add arc to both the state definition and the network

287 state_def.outgoing_arcs.append(arc)

288 # Also register the arc with the network for execution

289 # Extract function names for network registration

290 pre_test_name = None

291 transform_name = None

292 if arc.pre_test:

293 pre_test_name = getattr(arc.pre_test, '__name__', str(arc.pre_test))

294 if arc.transform:

295 transform_name = getattr(arc.transform, '__name__', str(arc.transform))

296

297 network.add_arc(

298 source_state=state_config.name,

299 target_state=arc_config.target,

300 pre_test=pre_test_name,

301 transform=transform_name,

302 metadata=arc_config.metadata

303 )

304

305 return network

306

307 def _build_state(self, state_config: StateConfig, fsm_config: FSMConfig) -> StateDefinition:

308 """Build a state definition from configuration.

309

310 Args:

311 state_config: State configuration.

312 fsm_config: Parent FSM configuration.

313

314 Returns:

315 StateDefinition instance.

316 """

317 # Build schema if provided

318 schema = None

319 if state_config.data_schema:

320 schema = self._build_schema(state_config.data_schema)

321

322 # Resolve pre-validators

323 pre_validators = []

324 for func_ref in state_config.pre_validators:

325 pre_validator = self._resolve_function(func_ref, IValidationFunction)

326 pre_validators.append(pre_validator)

327

328 # Resolve validators

329 validators = []

330 for func_ref in state_config.validators:

331 validator = self._resolve_function(func_ref, IValidationFunction)

332 validators.append(validator)

333

334 # Resolve transforms

335 transforms = []

336 for func_ref in state_config.transforms:

337 transform = self._resolve_function(func_ref, ITransformFunction)

338 transforms.append(transform)

339 # Don't re-register wrapped functions - they're already in the manager

340 # The transform is already an InterfaceWrapper with proper async handling

341

342 # Determine data mode

343 data_mode = state_config.data_mode or fsm_config.data_mode.default

344

345 # Create state definition with correct field names

346 state_def = StateDefinition(name=state_config.name)

347 state_def.schema = schema

348 state_def.pre_validation_functions = pre_validators

349 state_def.validation_functions = validators

350 state_def.transform_functions = transforms

351 # Look up actual resource configs from the FSM config

352 resource_map = {res.name: res for res in fsm_config.resources}

353 state_def.resource_requirements = [

354 resource_map[r] if r in resource_map else ResourceConfig(name=r, type=ResourceType.CUSTOM)

355 for r in state_config.resources

356 ]

357 state_def.data_mode = data_mode

358 # Handle states that are both start and end

359 if state_config.is_start and state_config.is_end:

360 state_def.type = StateType.START_END

361 elif state_config.is_start:

362 state_def.type = StateType.START

363 elif state_config.is_end:

364 state_def.type = StateType.END

365 else:

366 state_def.type = StateType.NORMAL

367 state_def.metadata = state_config.metadata

368

369 return state_def

370

371 def _get_function_name(self, func: Any) -> str | None:

372 """Extract the name from a function or wrapped function.

373

374 Args:

375 func: Function or wrapped function

376

377 Returns:

378 Function name or None

379 """

380 if not func:

381 return None

382

383 # Check for various name attributes

384 if hasattr(func, 'name'):

385 return func.name

386 elif hasattr(func, '__name__'):

387 # Skip generic names that would cause collisions

388 name = func.__name__

389 if name not in ['<lambda>', 'inline_func']:

390 return name

391 elif hasattr(func, 'wrapper') and hasattr(func.wrapper, 'name'):

392 # InterfaceWrapper case

393 return func.wrapper.name

394 else:

395 # Search for the function in the manager

396 for fname in self._function_manager.list_functions():

397 wrapper = self._function_manager.get_function(fname)

398 if wrapper and wrapper.func == func:

399 return fname

400 return None

401

402 def _build_arc(

403 self,

404 arc_config: ArcConfig,

405 source_state: StateDefinition,

406 network: StateNetwork,

407 fsm_config: FSMConfig,

408 definition_order: int = 0,

409 ) -> ArcDefinition:

410 """Build an arc definition from configuration.

411

412 Args:

413 arc_config: Arc configuration.

414 source_state: Source state definition.

415 network: Parent network.

416 fsm_config: Parent FSM configuration.

417 definition_order: Order in which this arc was defined.

418

419 Returns:

420 ArcDefinition instance.

421 """

422 # Resolve condition function

423 condition = None

424 condition_name = None

425 if arc_config.condition:

426 condition = self._resolve_function(arc_config.condition, IStateTestFunction)

427 # Get or generate a unique name for the condition

428 condition_name = self._get_function_name(condition)

429 # Lambda functions get the unhelpful name "<lambda>" so we need to generate a unique name

430 if not condition_name or condition_name == "<lambda>":

431 # Generate a unique name based on arc endpoints and code/function id

432 if arc_config.condition.type == "inline" and arc_config.condition.code:

433 # Use hash of code for uniqueness

434 condition_name = f"condition_{source_state.name}_{arc_config.target}_{abs(hash(arc_config.condition.code))}"

435 else:

436 condition_name = f"condition_{source_state.name}_{arc_config.target}_{id(condition)}"

437 # Register the function with the function manager so it gets transferred to FSM later

438 if condition_name and not self._function_manager.has_function(condition_name):

439 # Register the resolved function - if it's an InterfaceWrapper, register it as-is

440 # since InterfaceWrapper is callable and handles the interface correctly

441 if hasattr(condition, 'test'):

442 # It's an IStateTestFunction interface, register the test method

443 self._function_manager.register_function(condition_name, condition.test, FunctionSource.INLINE)

444 else:

445 # Register as-is

446 self._function_manager.register_function(condition_name, condition, FunctionSource.INLINE)

447

448 # Resolve transform function

449 transform = None

450 transform_name = None

451 if arc_config.transform:

452 transform = self._resolve_function(arc_config.transform, ITransformFunction)

453 # Get or generate a unique name for the transform

454 transform_name = self._get_function_name(transform)

455 # Lambda functions get the unhelpful name "<lambda>" so we need to generate a unique name

456 if not transform_name or transform_name == "<lambda>":

457 # Generate a unique name based on arc endpoints and code/function id

458 if arc_config.transform.type == "inline" and arc_config.transform.code:

459 # Use hash of code for uniqueness

460 transform_name = f"transform_{source_state.name}_{arc_config.target}_{abs(hash(arc_config.transform.code))}"

461 else:

462 transform_name = f"transform_{source_state.name}_{arc_config.target}_{id(transform)}"

463 # Register the function with the function manager so it gets transferred to FSM later

464 if transform_name and not self._function_manager.has_function(transform_name):

465 # Register the resolved function - we need the actual callable

466 self._function_manager.register_function(transform_name, transform, FunctionSource.INLINE)

467

468 # Create appropriate arc type

469 if isinstance(arc_config, PushArcConfig):

470 # Use the names we determined above

471 pre_test_name = condition_name

472 arc_transform_name = transform_name

473

474 # Push arc to another network

475 arc = PushArc(

476 target_state=arc_config.target, # Even push arcs have a target state

477 target_network=arc_config.target_network,

478 return_state=arc_config.return_state,

479 pre_test=pre_test_name,

480 transform=arc_transform_name,

481 priority=arc_config.priority,

482 definition_order=definition_order,

483 metadata=arc_config.metadata,

484 )

485 # Store resources separately if needed

486 arc.required_resources = {r: r for r in arc_config.resources}

487 return arc

488 else:

489 # Regular arc within network

490 # Use the names we determined above

491 pre_test_name = condition_name

492 arc_transform_name = transform_name

493

494 arc = ArcDefinition(

495 target_state=arc_config.target,

496 pre_test=pre_test_name,

497 transform=arc_transform_name,

498 priority=arc_config.priority,

499 definition_order=definition_order,

500 metadata=arc_config.metadata,

501 )

502 # Store resources separately if needed

503 arc.required_resources = {r: r for r in arc_config.resources}

504 return arc

505

506 def _build_schema(self, schema_config: Dict[str, Any]) -> Any:

507 """Build a schema from configuration.

508

509 Args:

510 schema_config: Schema configuration (JSON Schema format).

511

512 Returns:

513 Schema object for validation.

514 """

515 # Handle JSON Schema format - create a simple validation schema

516 # that can be used by the FSM's validation system

517

518 # For JSON Schema format, create a simple validator wrapper

519 class JSONSchemaValidator:

520 def __init__(self, schema_def):

521 self.schema_def = schema_def

522

523 def validate(self, data):

524 """Validate data against JSON schema."""

525 from dataknobs_data import Record

526

527 # Convert Record to dict if needed

528 if isinstance(data, Record):

529 data_dict = data.to_dict()

530 elif isinstance(data, dict):

531 data_dict = data

532 else:

533 return type('Result', (), {

534 'valid': False,

535 'errors': [f'Expected object or Record, got {type(data).__name__}']

536 })()

537

538 # Simple validation for basic JSON schema

539 if self.schema_def.get('type') == 'object':

540 errors = []

541 properties = self.schema_def.get('properties', {})

542 required = self.schema_def.get('required', [])

543

544 # Check required fields

545 for field in required:

546 if field not in data_dict:

547 errors.append(f"Required field '{field}' is missing")

548

549 # Check field types

550 for field, value in data_dict.items():

551 if field in properties:

552 field_schema = properties[field]

553 field_type = field_schema.get('type')

554 if field_type and not self._validate_type(value, field_type):

555 errors.append(f"Field '{field}' has wrong type")

556

557 return type('Result', (), {

558 'valid': len(errors) == 0,

559 'errors': errors

560 })()

561 else:

562 # Simple pass-through for non-object schemas

563 return type('Result', (), {'valid': True, 'errors': []})()

564

565 def _validate_type(self, value, expected_type):

566 """Validate value type."""

567 type_map = {

568 'string': str,

569 'integer': int,

570 'number': (int, float),

571 'boolean': bool,

572 'array': list,

573 'object': dict

574 }

575 expected_python_type = type_map.get(expected_type)

576 if expected_python_type:

577 return isinstance(value, expected_python_type)

578 return True

579

580 return JSONSchemaValidator(schema_config)

581

582 def _resolve_function(

583 self,

584 func_ref: FunctionReference,

585 expected_type: Type | None = None,

586 ) -> Callable:

587 """Resolve a function reference to a callable.

588

589 Args:

590 func_ref: Function reference.

591 expected_type: Expected function interface type.

592

593 Returns:

594 Resolved function callable.

595

596 Raises:

597 ValueError: If function cannot be resolved.

598 """

599 if func_ref.type == "builtin":

600 # Look up built-in function

601 wrapper = self._function_manager.get_function(func_ref.name)

602 if not wrapper:

603 raise ValueError(f"Built-in function not found: {func_ref.name}")

604 func = wrapper

605

606 elif func_ref.type == "registered":

607 # Look up registered function

608 wrapper = self._function_manager.get_function(func_ref.name)

609 if not wrapper:

610 raise ValueError(f"Registered function not found: {func_ref.name}")

611 func = wrapper

612

613 elif func_ref.type == "custom":

614 # Check manager first

615 wrapper = self._function_manager.get_function(func_ref.name)

616 if wrapper:

617 func = wrapper

618 else:

619 # Import custom function

620 module = importlib.import_module(func_ref.module)

621 func = getattr(module, func_ref.name)

622 # Register it for future use

623 self._function_manager.register_function(func_ref.name, func, FunctionSource.REGISTERED)

624

625 elif func_ref.type == "inline":

626 # Use function manager's inline handling

627 wrapper = self._function_manager.resolve_function(func_ref.code, expected_type)

628 if not wrapper:

629 raise ValueError(f"Failed to create inline function from: {func_ref.code}")

630 func = wrapper

631 # Mark as already wrapped to avoid double wrapping

632 func._is_wrapped = True

633

634 else:

635 raise ValueError(f"Unknown function type: {func_ref.type}")

636

637 # Apply parameters if provided

638 if func_ref.params:

639 # Create a partial function with parameters

640 import functools

641 func = functools.partial(func, **func_ref.params)

642

643 # Validate type if specified

644 # Skip wrapping if already wrapped by function manager

645 if expected_type and not isinstance(func, expected_type) and not getattr(func, '_is_wrapped', False):

646 # Wrap function to match expected interface

647 wrapped = self._wrap_function(func, expected_type)

648 # Preserve the original function name if it exists

649 if hasattr(func, 'name'):

650 wrapped.name = func.name

651 elif func_ref.type == "registered" and func_ref.name:

652 wrapped.name = func_ref.name

653 func = wrapped

654

655 # For inline functions, ensure they have a name

656 if func_ref.type == "inline" and hasattr(func, 'name'):

657 # Name already set by function manager

658 pass

659

660 return func

661

662 def _wrap_function(self, func: Callable, interface: Type) -> Any:

663 """Wrap a function to match an expected interface using unified manager.

664

665 Args:

666 func: Function to wrap.

667 interface: Expected interface type.

668

669 Returns:

670 Wrapped function implementing the interface.

671 """

672 # Resolve the function through the unified manager

673 wrapper = self._function_manager.resolve_function(func, interface)

674 return wrapper

675

676 def _validate_completeness(self, config: FSMConfig) -> None:

677 """Validate that the FSM configuration is complete and consistent.

678

679 Args:

680 config: FSM configuration.

681

682 Raises:

683 ValueError: If configuration is incomplete or inconsistent.

684 """

685 # Check main network exists

686 if config.main_network not in self._networks:

687 raise ValueError(f"Main network '{config.main_network}' not found")

688

689 # Check all arc targets exist

690 for network in self._networks.values():

691 state_names = {state.name for state in network.states.values()}

692 for state in network.states.values():

693 for arc in network.get_arcs_from_state(state.name):

694 if isinstance(arc, PushArc):

695 # Check target network exists

696 if arc.target_network not in self._networks:

697 raise ValueError(f"Target network '{arc.target_network}' not found")

698 # Check return state exists if specified

699 if arc.return_state and arc.return_state not in state_names:

700 raise ValueError(f"Return state '{arc.return_state}' not found")

701 else:

702 # Check target state exists

703 if arc.target_state not in state_names:

704 raise ValueError(f"Arc target '{arc.target_state}' not found in network")

705

706 # Check resource references

707 resource_names = {res.name for res in config.resources}

708 for network in self._networks.values():

709 for state in network.states.values():

710 for resource_req in state.resource_requirements:

711 if resource_req.name not in resource_names:

712 raise ValueError(f"Resource '{resource_req.name}' not found")

713

714 def _create_execution_context(self, config: FSMConfig) -> ExecutionContext:

715 """Create execution context from configuration.

716

717 Args:

718 config: FSM configuration.

719

720 Returns:

721 ExecutionContext instance.

722 """

723 # Map our DataHandlingMode to the execution context's ProcessingMode

724 from dataknobs_fsm.core.modes import ProcessingMode as CoreDataMode

725

726 # Simple mapping - we'll use SINGLE mode for now

727 # This could be enhanced to support batch and stream modes

728 return ExecutionContext(

729 data_mode=CoreDataMode.SINGLE,

730 resources={} # Resources will be populated during execution

731 )

732

733

734# FSM wrapper class removed - functionality moved to core FSM

735# The FSMBuilder now returns the core FSM directly with all

736# execution capabilities integrated