Coverage for src/dataknobs_fsm/functions/library/validators.py: 19%

1"""Built-in validator functions for FSM.

2

3 This module provides commonly used validation functions that can be

4 referenced in FSM configurations.

5"""

6

7 import re

8 from typing import Any, Dict, List, Union

9

10 from pydantic import BaseModel, ValidationError

11

12 from dataknobs_fsm.functions.base import IValidationFunction, ValidationError as FSMValidationError

13

14

15 class RequiredFieldsValidator(IValidationFunction):

16 """Validate that required fields are present in data."""

17

18 def __init__(self, fields: List[str], allow_none: bool = False):

19 """Initialize the validator.

20

21 Args:

22 fields: List of required field names.

23 allow_none: Whether to allow None values for required fields.

24 """

25 self.fields = fields

26 self.allow_none = allow_none

27

28 def validate(self, data: Dict[str, Any]) -> bool:

29 """Validate that all required fields are present.

30

31 Args:

32 data: Data to validate.

33

34 Returns:

35 True if valid, False otherwise.

36

37 Raises:

38 FSMValidationError: If validation fails with details.

39 """

40 if not isinstance(data, dict):

41 raise FSMValidationError(f"Expected dict, got {type(data).__name__}")

42

43 missing_fields = []

44 none_fields = []

45

46 for field in self.fields:

47 if field not in data:

48 missing_fields.append(field)

49 elif not self.allow_none and data[field] is None:

50 none_fields.append(field)

51

52 if missing_fields:

53 raise FSMValidationError(

54 f"Missing required fields: {', '.join(missing_fields)}"

55 )

56

57 if none_fields:

58 raise FSMValidationError(

59 f"Fields cannot be None: {', '.join(none_fields)}"

60 )

61

62 return True

63

64

65 class SchemaValidator(IValidationFunction):

66 """Validate data against a Pydantic schema."""

67

68 def __init__(self, schema: Union[type[BaseModel], Dict[str, Any]]):

69 """Initialize the validator.

70

71 Args:

72 schema: Pydantic model class or schema dictionary.

73 """

74 if isinstance(schema, dict):

75 # Create a dynamic Pydantic model from dictionary

76 from pydantic import create_model

77 self.schema = create_model("DynamicSchema", **schema)

78 else:

79 self.schema = schema

80

81 def validate(self, data: Dict[str, Any]) -> bool:

82 """Validate data against the schema.

83

84 Args:

85 data: Data to validate.

86

87 Returns:

88 True if valid, False otherwise.

89

90 Raises:

91 FSMValidationError: If validation fails with details.

92 """

93 try:

94 self.schema(**data)

95 return True

96 except ValidationError as e:

97 errors = []

98 for error in e.errors():

99 field_path = ".".join(str(loc) for loc in error["loc"])

100 errors.append(f"{field_path}: {error['msg']}")

101

102 raise FSMValidationError(

103 f"Schema validation failed: {'; '.join(errors)}"

104 ) from e

105

106 def get_validation_rules(self) -> Dict[str, Any]:

107 """Get the validation rules."""

108 if hasattr(self.schema, 'model_json_schema'):

109 return self.schema.model_json_schema()

110 elif hasattr(self.schema, '__annotations__'):

111 return dict(self.schema.__annotations__)

112 else:

113 return {"schema": str(self.schema)}

114

115

116 class RangeValidator(IValidationFunction):

117 """Validate that numeric values are within specified ranges."""

118

119 def __init__(

120 self,

121 field_ranges: Dict[str, Dict[str, Union[int, float]]],

122 ):

123 """Initialize the validator.

124

125 Args:

126 field_ranges: Dictionary mapping field names to range specifications.

127 Each range can have 'min', 'max', or both.

128 """

129 self.field_ranges = field_ranges

130

131 def validate(self, data: Dict[str, Any]) -> bool:

132 """Validate that values are within specified ranges.

133

134 Args:

135 data: Data to validate.

136

137 Returns:

138 True if valid, False otherwise.

139

140 Raises:

141 FSMValidationError: If validation fails with details.

142 """

143 errors = []

144

145 for field, range_spec in self.field_ranges.items():

146 if field not in data:

147 continue

148

149 value = data[field]

150 if not isinstance(value, (int, float)):

151 errors.append(f"{field}: Expected numeric value, got {type(value).__name__}")

152 continue

153

154 if "min" in range_spec and value < range_spec["min"]:

155 errors.append(f"{field}: Value {value} is below minimum {range_spec['min']}")

156

157 if "max" in range_spec and value > range_spec["max"]:

158 errors.append(f"{field}: Value {value} is above maximum {range_spec['max']}")

159

160 if errors:

161 raise FSMValidationError("; ".join(errors))

162

163 return True

164

165

166 class PatternValidator(IValidationFunction):

167 """Validate that string values match specified patterns."""

168

169 def __init__(

170 self,

171 field_patterns: Dict[str, str],

172 flags: int = 0,

173 ):

174 """Initialize the validator.

175

176 Args:

177 field_patterns: Dictionary mapping field names to regex patterns.

178 flags: Regex flags to apply (e.g., re.IGNORECASE).

179 """

180 self.field_patterns = {}

181 for field, pattern in field_patterns.items():

182 self.field_patterns[field] = re.compile(pattern, flags)

183

184 def validate(self, data: Dict[str, Any]) -> bool:

185 """Validate that values match specified patterns.

186

187 Args:

188 data: Data to validate.

189

190 Returns:

191 True if valid, False otherwise.

192

193 Raises:

194 FSMValidationError: If validation fails with details.

195 """

196 errors = []

197

198 for field, pattern in self.field_patterns.items():

199 if field not in data:

200 continue

201

202 value = data[field]

203 if not isinstance(value, str):

204 errors.append(f"{field}: Expected string value, got {type(value).__name__}")

205 continue

206

207 if not pattern.match(value):

208 errors.append(f"{field}: Value '{value}' does not match pattern")

209

210 if errors:

211 raise FSMValidationError("; ".join(errors))

212

213 return True

214

215

216 class TypeValidator(IValidationFunction):

217 """Validate that fields have expected types."""

218

219 def __init__(

220 self,

221 field_types: Dict[str, Union[type, List[type]]],

222 strict: bool = False,

223 ):

224 """Initialize the validator.

225

226 Args:

227 field_types: Dictionary mapping field names to expected types.

228 strict: If True, reject extra fields not in field_types.

229 """

230 self.field_types = field_types

231 self.strict = strict

232

233 def validate(self, data: Dict[str, Any]) -> bool:

234 """Validate that fields have expected types.

235

236 Args:

237 data: Data to validate.

238

239 Returns:

240 True if valid, False otherwise.

241

242 Raises:

243 FSMValidationError: If validation fails with details.

244 """

245 errors = []

246

247 # Check field types

248 for field, expected_type in self.field_types.items():

249 if field not in data:

250 continue

251

252 value = data[field]

253 if isinstance(expected_type, list):

254 # Multiple allowed types

255 if not any(isinstance(value, t) for t in expected_type):

256 type_names = ", ".join(t.__name__ for t in expected_type)

257 errors.append(

258 f"{field}: Expected one of [{type_names}], "

259 f"got {type(value).__name__}"

260 )

261 else:

262 # Single expected type

263 if not isinstance(value, expected_type):

264 errors.append(

265 f"{field}: Expected {expected_type.__name__}, "

266 f"got {type(value).__name__}"

267 )

268

269 # Check for extra fields if strict mode

270 if self.strict:

271 extra_fields = set(data.keys()) - set(self.field_types.keys())

272 if extra_fields:

273 errors.append(f"Unexpected fields: {', '.join(extra_fields)}")

274

275 if errors:

276 raise FSMValidationError("; ".join(errors))

277

278 return True

279

280

281 class LengthValidator(IValidationFunction):

282 """Validate that collections have expected lengths."""

283

284 def __init__(

285 self,

286 field_lengths: Dict[str, Dict[str, int]],

287 ):

288 """Initialize the validator.

289

290 Args:

291 field_lengths: Dictionary mapping field names to length specifications.

292 Each spec can have 'min', 'max', or 'exact'.

293 """

294 self.field_lengths = field_lengths

295

296 def validate(self, data: Dict[str, Any]) -> bool:

297 """Validate that collections have expected lengths.

298

299 Args:

300 data: Data to validate.

301

302 Returns:

303 True if valid, False otherwise.

304

305 Raises:

306 FSMValidationError: If validation fails with details.

307 """

308 errors = []

309

310 for field, length_spec in self.field_lengths.items():

311 if field not in data:

312 continue

313

314 value = data[field]

315 if not hasattr(value, "__len__"):

316 errors.append(f"{field}: Value does not have a length")

317 continue

318

319 length = len(value)

320

321 if "exact" in length_spec and length != length_spec["exact"]:

322 errors.append(

323 f"{field}: Length {length} does not match expected {length_spec['exact']}"

324 )

325

326 if "min" in length_spec and length < length_spec["min"]:

327 errors.append(f"{field}: Length {length} is below minimum {length_spec['min']}")

328

329 if "max" in length_spec and length > length_spec["max"]:

330 errors.append(f"{field}: Length {length} is above maximum {length_spec['max']}")

331

332 if errors:

333 raise FSMValidationError("; ".join(errors))

334

335 return True

336

337

338 class UniqueValidator(IValidationFunction):

339 """Validate that values in collections are unique."""

340

341 def __init__(

342 self,

343 fields: List[str],

344 key: str | None = None,

345 ):

346 """Initialize the validator.

347

348 Args:

349 fields: List of field names to check for uniqueness.

350 key: Optional key to extract from collection items for uniqueness check.

351 """

352 self.fields = fields

353 self.key = key

354

355 def validate(self, data: Dict[str, Any]) -> bool:

356 """Validate that values are unique.

357

358 Args:

359 data: Data to validate.

360

361 Returns:

362 True if valid, False otherwise.

363

364 Raises:

365 FSMValidationError: If validation fails with details.

366 """

367 errors = []

368

369 for field in self.fields:

370 if field not in data:

371 continue

372

373 value = data[field]

374 if not isinstance(value, (list, tuple, set)):

375 errors.append(f"{field}: Expected collection, got {type(value).__name__}")

376 continue

377

378 if self.key:

379 # Extract values using key

380 try:

381 values = [item[self.key] if isinstance(item, dict) else getattr(item, self.key)

382 for item in value]

383 except (KeyError, AttributeError) as e:

384 errors.append(f"{field}: Cannot extract key '{self.key}': {e}")

385 continue

386 else:

387 values = list(value)

388

389 # Check for duplicates

390 seen = set()

391 duplicates = set()

392 for v in values:

393 if v in seen:

394 duplicates.add(str(v))

395 seen.add(v)

396

397 if duplicates:

398 errors.append(f"{field}: Duplicate values found: {', '.join(duplicates)}")

399

400 if errors:

401 raise FSMValidationError("; ".join(errors))

402

403 return True

404

405

406 class DependencyValidator(IValidationFunction):

407 """Validate field dependencies (if field A exists, field B must also exist)."""

408

409 def __init__(

410 self,

411 dependencies: Dict[str, Union[str, List[str]]],

412 ):

413 """Initialize the validator.

414

415 Args:

416 dependencies: Dictionary mapping field names to their dependencies.

417 """

418 self.dependencies = dependencies

419

420 def validate(self, data: Dict[str, Any]) -> bool:

421 """Validate field dependencies.

422

423 Args:

424 data: Data to validate.

425

426 Returns:

427 True if valid, False otherwise.

428

429 Raises:

430 FSMValidationError: If validation fails with details.

431 """

432 errors = []

433

434 for field, deps in self.dependencies.items():

435 if field not in data:

436 continue

437

438 deps_list = deps if isinstance(deps, list) else [deps]

439

440 missing_deps = [dep for dep in deps_list if dep not in data]

441

442 if missing_deps:

443 errors.append(

444 f"Field '{field}' requires: {', '.join(missing_deps)}"

445 )

446

447 if errors:

448 raise FSMValidationError("; ".join(errors))

449

450 return True

451

452

453 class CompositeValidator(IValidationFunction):

454 """Compose multiple validators into a single validator."""

455

456 def __init__(

457 self,

458 validators: List[IValidationFunction],

459 stop_on_first_error: bool = False,

460 ):

461 """Initialize the composite validator.

462

463 Args:

464 validators: List of validators to apply.

465 stop_on_first_error: If True, stop at first validation error.

466 """

467 self.validators = validators

468 self.stop_on_first_error = stop_on_first_error

469

470 def validate(self, data: Dict[str, Any]) -> bool:

471 """Apply all validators to the data.

472

473 Args:

474 data: Data to validate.

475

476 Returns:

477 True if all validators pass.

478

479 Raises:

480 FSMValidationError: If any validation fails.

481 """

482 errors = []

483

484 for validator in self.validators:

485 try:

486 validator.validate(data)

487 except FSMValidationError as e:

488 if self.stop_on_first_error:

489 raise

490 errors.append(str(e))

491

492 if errors:

493 raise FSMValidationError("; ".join(errors))

494

495 return True

496

497

498# Convenience functions for creating validators

499 def required_fields(*fields: str, allow_none: bool = False) -> RequiredFieldsValidator:

500 """Create a RequiredFieldsValidator."""

501 return RequiredFieldsValidator(list(fields), allow_none)

502

503

504 def schema(model: Union[type[BaseModel], Dict[str, Any]]) -> SchemaValidator:

505 """Create a SchemaValidator."""

506 return SchemaValidator(model)

507

508

509 def range_check(**field_ranges: Dict[str, Union[int, float]]) -> RangeValidator:

510 """Create a RangeValidator."""

511 return RangeValidator(field_ranges)

512

513

514 def pattern(**field_patterns: str) -> PatternValidator:

515 """Create a PatternValidator."""

516 return PatternValidator(field_patterns)

517

518

519 def type_check(**field_types: Union[type, List[type]]) -> TypeValidator:

520 """Create a TypeValidator."""

521 return TypeValidator(field_types)

522

523

524 def length(**field_lengths: Dict[str, int]) -> LengthValidator:

525 """Create a LengthValidator."""

526 return LengthValidator(field_lengths)

527

528

529 def unique(*fields: str, key: str | None = None) -> UniqueValidator:

530 """Create a UniqueValidator."""

531 return UniqueValidator(list(fields), key)

532

533

534 def depends_on(**dependencies: Union[str, List[str]]) -> DependencyValidator:

535 """Create a DependencyValidator."""

536 return DependencyValidator(dependencies)