grai-build 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,475 @@
1
+ """
2
+ Project validator for grai.build.
3
+
4
+ This module provides functions to validate that entity and relation definitions
5
+ are consistent, that all references exist, and that key mappings are valid.
6
+ """
7
+
8
+ from typing import Dict, List, Optional
9
+
10
+ from grai.core.models import Entity, Project, Relation
11
+
12
+
13
+ class ValidationError(Exception):
14
+ """Base exception for validation errors."""
15
+
16
+ def __init__(self, message: str, context: Optional[str] = None):
17
+ """
18
+ Initialize validation error.
19
+
20
+ Args:
21
+ message: Error message.
22
+ context: Optional context (e.g., entity or relation name).
23
+ """
24
+ self.context = context
25
+ if context:
26
+ super().__init__(f"{context}: {message}")
27
+ else:
28
+ super().__init__(message)
29
+
30
+
31
+ class EntityReferenceError(ValidationError):
32
+ """Exception raised when an entity reference is invalid."""
33
+
34
+ pass
35
+
36
+
37
+ class KeyMappingError(ValidationError):
38
+ """Exception raised when a key mapping is invalid."""
39
+
40
+ pass
41
+
42
+
43
+ class ValidationResult:
44
+ """
45
+ Result of a validation operation.
46
+
47
+ Attributes:
48
+ valid: Whether validation passed.
49
+ errors: List of validation errors.
50
+ warnings: List of validation warnings.
51
+ """
52
+
53
+ def __init__(self):
54
+ """Initialize validation result."""
55
+ self.valid: bool = True
56
+ self.errors: List[str] = []
57
+ self.warnings: List[str] = []
58
+
59
+ def add_error(self, message: str, context: Optional[str] = None) -> None:
60
+ """
61
+ Add an error to the result.
62
+
63
+ Args:
64
+ message: Error message.
65
+ context: Optional context.
66
+ """
67
+ self.valid = False
68
+ if context:
69
+ self.errors.append(f"{context}: {message}")
70
+ else:
71
+ self.errors.append(message)
72
+
73
+ def add_warning(self, message: str, context: Optional[str] = None) -> None:
74
+ """
75
+ Add a warning to the result.
76
+
77
+ Args:
78
+ message: Warning message.
79
+ context: Optional context.
80
+ """
81
+ if context:
82
+ self.warnings.append(f"{context}: {message}")
83
+ else:
84
+ self.warnings.append(message)
85
+
86
+ def __bool__(self) -> bool:
87
+ """Return whether validation passed."""
88
+ return self.valid
89
+
90
+ def __str__(self) -> str:
91
+ """Return string representation of validation result."""
92
+ lines = []
93
+ if self.errors:
94
+ lines.append(f"Errors ({len(self.errors)}):")
95
+ for error in self.errors:
96
+ lines.append(f" • {error}")
97
+ if self.warnings:
98
+ lines.append(f"Warnings ({len(self.warnings)}):")
99
+ for warning in self.warnings:
100
+ lines.append(f" • {warning}")
101
+ if not self.errors and not self.warnings:
102
+ lines.append("✅ Validation passed with no errors or warnings")
103
+ return "\n".join(lines)
104
+
105
+
106
+ def build_entity_index(entities: List[Entity]) -> Dict[str, Entity]:
107
+ """
108
+ Build an index of entities by name for quick lookup.
109
+
110
+ Args:
111
+ entities: List of entities.
112
+
113
+ Returns:
114
+ Dictionary mapping entity names to Entity objects.
115
+ """
116
+ return {entity.entity: entity for entity in entities}
117
+
118
+
119
+ def validate_entity_references(
120
+ relations: List[Relation],
121
+ entity_index: Dict[str, Entity],
122
+ result: Optional[ValidationResult] = None,
123
+ ) -> ValidationResult:
124
+ """
125
+ Validate that all entity references in relations exist.
126
+
127
+ Args:
128
+ relations: List of relations to validate.
129
+ entity_index: Index of entities by name.
130
+ result: Optional existing ValidationResult to add to.
131
+
132
+ Returns:
133
+ ValidationResult with any errors found.
134
+ """
135
+ if result is None:
136
+ result = ValidationResult()
137
+
138
+ for relation in relations:
139
+ # Check from_entity exists
140
+ if relation.from_entity not in entity_index:
141
+ result.add_error(
142
+ f"References non-existent entity '{relation.from_entity}'",
143
+ context=f"Relation {relation.relation}",
144
+ )
145
+
146
+ # Check to_entity exists
147
+ if relation.to_entity not in entity_index:
148
+ result.add_error(
149
+ f"References non-existent entity '{relation.to_entity}'",
150
+ context=f"Relation {relation.relation}",
151
+ )
152
+
153
+ return result
154
+
155
+
156
+ def validate_key_mappings(
157
+ relations: List[Relation],
158
+ entity_index: Dict[str, Entity],
159
+ result: Optional[ValidationResult] = None,
160
+ ) -> ValidationResult:
161
+ """
162
+ Validate that key mappings in relations reference valid entity keys.
163
+
164
+ Args:
165
+ relations: List of relations to validate.
166
+ entity_index: Index of entities by name.
167
+ result: Optional existing ValidationResult to add to.
168
+
169
+ Returns:
170
+ ValidationResult with any errors found.
171
+ """
172
+ if result is None:
173
+ result = ValidationResult()
174
+
175
+ for relation in relations:
176
+ # Skip if entities don't exist (caught by entity_references validation)
177
+ if relation.from_entity not in entity_index or relation.to_entity not in entity_index:
178
+ continue
179
+
180
+ from_entity = entity_index[relation.from_entity]
181
+ to_entity = entity_index[relation.to_entity]
182
+
183
+ # Check from_key exists in from_entity
184
+ if relation.mappings.from_key not in from_entity.keys:
185
+ result.add_error(
186
+ f"Key '{relation.mappings.from_key}' not found in entity '{relation.from_entity}' keys: {from_entity.keys}",
187
+ context=f"Relation {relation.relation}",
188
+ )
189
+
190
+ # Check to_key exists in to_entity
191
+ if relation.mappings.to_key not in to_entity.keys:
192
+ result.add_error(
193
+ f"Key '{relation.mappings.to_key}' not found in entity '{relation.to_entity}' keys: {to_entity.keys}",
194
+ context=f"Relation {relation.relation}",
195
+ )
196
+
197
+ return result
198
+
199
+
200
+ def validate_property_definitions(
201
+ entities: List[Entity],
202
+ relations: List[Relation],
203
+ result: Optional[ValidationResult] = None,
204
+ ) -> ValidationResult:
205
+ """
206
+ Validate property definitions in entities and relations.
207
+
208
+ Args:
209
+ entities: List of entities to validate.
210
+ relations: List of relations to validate.
211
+ result: Optional existing ValidationResult to add to.
212
+
213
+ Returns:
214
+ ValidationResult with any errors or warnings found.
215
+ """
216
+ if result is None:
217
+ result = ValidationResult()
218
+
219
+ # Validate entities
220
+ for entity in entities:
221
+ # Check for duplicate property names
222
+ property_names = [p.name for p in entity.properties]
223
+ duplicates = set([name for name in property_names if property_names.count(name) > 1])
224
+ if duplicates:
225
+ result.add_error(
226
+ f"Duplicate property names: {', '.join(duplicates)}",
227
+ context=f"Entity {entity.entity}",
228
+ )
229
+
230
+ # Check that all keys have corresponding properties
231
+ property_name_set = set(property_names)
232
+ for key in entity.keys:
233
+ if key not in property_name_set:
234
+ result.add_warning(
235
+ f"Key '{key}' does not have a corresponding property definition",
236
+ context=f"Entity {entity.entity}",
237
+ )
238
+
239
+ # Validate relations
240
+ for relation in relations:
241
+ # Check for duplicate property names
242
+ property_names = [p.name for p in relation.properties]
243
+ duplicates = set([name for name in property_names if property_names.count(name) > 1])
244
+ if duplicates:
245
+ result.add_error(
246
+ f"Duplicate property names: {', '.join(duplicates)}",
247
+ context=f"Relation {relation.relation}",
248
+ )
249
+
250
+ return result
251
+
252
+
253
+ def validate_unique_names(
254
+ entities: List[Entity],
255
+ relations: List[Relation],
256
+ result: Optional[ValidationResult] = None,
257
+ ) -> ValidationResult:
258
+ """
259
+ Validate that entity and relation names are unique.
260
+
261
+ Args:
262
+ entities: List of entities to check.
263
+ relations: List of relations to check.
264
+ result: Optional existing ValidationResult to add to.
265
+
266
+ Returns:
267
+ ValidationResult with any duplicate names found.
268
+ """
269
+ if result is None:
270
+ result = ValidationResult()
271
+
272
+ # Check for duplicate entity names
273
+ entity_names = [e.entity for e in entities]
274
+ duplicate_entities = set([name for name in entity_names if entity_names.count(name) > 1])
275
+ if duplicate_entities:
276
+ result.add_error(f"Duplicate entity names: {', '.join(duplicate_entities)}")
277
+
278
+ # Check for duplicate relation names
279
+ relation_names = [r.relation for r in relations]
280
+ duplicate_relations = set([name for name in relation_names if relation_names.count(name) > 1])
281
+ if duplicate_relations:
282
+ result.add_error(f"Duplicate relation names: {', '.join(duplicate_relations)}")
283
+
284
+ return result
285
+
286
+
287
+ def validate_sources(
288
+ entities: List[Entity],
289
+ relations: List[Relation],
290
+ result: Optional[ValidationResult] = None,
291
+ ) -> ValidationResult:
292
+ """
293
+ Validate that sources are properly defined.
294
+
295
+ Args:
296
+ entities: List of entities to check.
297
+ relations: List of relations to check.
298
+ result: Optional existing ValidationResult to add to.
299
+
300
+ Returns:
301
+ ValidationResult with any source issues found.
302
+ """
303
+ if result is None:
304
+ result = ValidationResult()
305
+
306
+ # Check entities have valid sources
307
+ for entity in entities:
308
+ source_name = entity.get_source_name()
309
+ if not source_name or not source_name.strip():
310
+ result.add_error(
311
+ "Entity has empty or missing source",
312
+ context=f"Entity {entity.entity}",
313
+ )
314
+
315
+ # Check relations have valid sources
316
+ for relation in relations:
317
+ source_name = relation.get_source_name()
318
+ if not source_name or not source_name.strip():
319
+ result.add_error(
320
+ "Relation has empty or missing source",
321
+ context=f"Relation {relation.relation}",
322
+ )
323
+
324
+ return result
325
+
326
+
327
+ def validate_project(
328
+ project: Project,
329
+ strict: bool = True,
330
+ ) -> ValidationResult:
331
+ """
332
+ Validate an entire project for consistency and correctness.
333
+
334
+ Args:
335
+ project: The project to validate.
336
+ strict: If True, warnings will be treated as errors.
337
+
338
+ Returns:
339
+ ValidationResult with all errors and warnings.
340
+
341
+ Raises:
342
+ ValidationError: If strict=True and validation fails.
343
+ """
344
+ result = ValidationResult()
345
+
346
+ # Build entity index for quick lookups
347
+ entity_index = build_entity_index(project.entities)
348
+
349
+ # Run all validations
350
+ validate_unique_names(project.entities, project.relations, result)
351
+ validate_sources(project.entities, project.relations, result)
352
+ validate_entity_references(project.relations, entity_index, result)
353
+ validate_key_mappings(project.relations, entity_index, result)
354
+ validate_property_definitions(project.entities, project.relations, result)
355
+
356
+ # Note: We don't check for circular dependencies because they are
357
+ # normal and expected in graph structures (e.g., bidirectional relationships,
358
+ # social networks, organizational hierarchies, etc.)
359
+
360
+ # In strict mode, treat warnings as errors
361
+ if strict and result.warnings:
362
+ for warning in result.warnings:
363
+ result.add_error(f"[Strict mode] {warning}")
364
+ result.warnings.clear()
365
+
366
+ return result
367
+
368
+
369
+ def validate_entity(entity: Entity) -> ValidationResult:
370
+ """
371
+ Validate a single entity.
372
+
373
+ Args:
374
+ entity: The entity to validate.
375
+
376
+ Returns:
377
+ ValidationResult with any errors or warnings.
378
+ """
379
+ result = ValidationResult()
380
+
381
+ # Check for empty keys
382
+ if not entity.keys:
383
+ result.add_error("Entity must have at least one key", context=f"Entity {entity.entity}")
384
+
385
+ # Check for duplicate property names
386
+ property_names = [p.name for p in entity.properties]
387
+ duplicates = set([name for name in property_names if property_names.count(name) > 1])
388
+ if duplicates:
389
+ result.add_error(
390
+ f"Duplicate property names: {', '.join(duplicates)}",
391
+ context=f"Entity {entity.entity}",
392
+ )
393
+
394
+ # Check that keys have properties
395
+ property_name_set = set(property_names)
396
+ for key in entity.keys:
397
+ if key not in property_name_set:
398
+ result.add_warning(
399
+ f"Key '{key}' does not have a corresponding property definition",
400
+ context=f"Entity {entity.entity}",
401
+ )
402
+
403
+ # Check source
404
+ source_name = entity.get_source_name()
405
+ if not source_name or not source_name.strip():
406
+ result.add_error("Entity has empty or missing source", context=f"Entity {entity.entity}")
407
+
408
+ return result
409
+
410
+
411
+ def validate_relation(
412
+ relation: Relation,
413
+ entity_index: Optional[Dict[str, Entity]] = None,
414
+ ) -> ValidationResult:
415
+ """
416
+ Validate a single relation.
417
+
418
+ Args:
419
+ relation: The relation to validate.
420
+ entity_index: Optional index of entities for reference checking.
421
+
422
+ Returns:
423
+ ValidationResult with any errors or warnings.
424
+ """
425
+ result = ValidationResult()
426
+
427
+ # Check for duplicate property names
428
+ property_names = [p.name for p in relation.properties]
429
+ duplicates = set([name for name in property_names if property_names.count(name) > 1])
430
+ if duplicates:
431
+ result.add_error(
432
+ f"Duplicate property names: {', '.join(duplicates)}",
433
+ context=f"Relation {relation.relation}",
434
+ )
435
+
436
+ # Check source
437
+ source_name = relation.get_source_name()
438
+ if not source_name or not source_name.strip():
439
+ result.add_error(
440
+ "Relation has empty or missing source",
441
+ context=f"Relation {relation.relation}",
442
+ )
443
+
444
+ # If entity_index provided, check references
445
+ if entity_index is not None:
446
+ if relation.from_entity not in entity_index:
447
+ result.add_error(
448
+ f"References non-existent entity '{relation.from_entity}'",
449
+ context=f"Relation {relation.relation}",
450
+ )
451
+
452
+ if relation.to_entity not in entity_index:
453
+ result.add_error(
454
+ f"References non-existent entity '{relation.to_entity}'",
455
+ context=f"Relation {relation.relation}",
456
+ )
457
+
458
+ # Check key mappings if entities exist
459
+ if relation.from_entity in entity_index and relation.to_entity in entity_index:
460
+ from_entity = entity_index[relation.from_entity]
461
+ to_entity = entity_index[relation.to_entity]
462
+
463
+ if relation.mappings.from_key not in from_entity.keys:
464
+ result.add_error(
465
+ f"Key '{relation.mappings.from_key}' not found in entity '{relation.from_entity}' keys: {from_entity.keys}",
466
+ context=f"Relation {relation.relation}",
467
+ )
468
+
469
+ if relation.mappings.to_key not in to_entity.keys:
470
+ result.add_error(
471
+ f"Key '{relation.mappings.to_key}' not found in entity '{relation.to_entity}' keys: {to_entity.keys}",
472
+ context=f"Relation {relation.relation}",
473
+ )
474
+
475
+ return result