eggpipe 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,518 @@
1
+ """Semantic model validation on top of Pydantic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import date, datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from eggpipe.core.document import ModelDocument
11
+ from eggpipe.core.model import (
12
+ PRIMITIVE_TYPES,
13
+ UNSET,
14
+ Constraints,
15
+ CustomType,
16
+ Entity,
17
+ EnumDef,
18
+ FieldDef,
19
+ ModelSlice,
20
+ SourceModel,
21
+ )
22
+ from eggpipe.interfaces.diagnostics import Diagnostic, error, warning
23
+
24
+ MAX_TYPE_DEPTH = 10
25
+ MAX_IMPORT_DEPTH = 10
26
+
27
+
28
+ def validate_entity_model(
29
+ model: SourceModel | ModelSlice,
30
+ *,
31
+ primitive_types: set[str] | frozenset[str] | None = None,
32
+ file: Path | None = None,
33
+ ) -> list[Diagnostic]:
34
+ primitives = set(PRIMITIVE_TYPES if primitive_types is None else primitive_types)
35
+ diagnostics: list[Diagnostic] = []
36
+ diagnostics.extend(_validate_duplicate_names(model, file))
37
+ diagnostics.extend(_validate_case_collisions(model, file))
38
+
39
+ type_bases = _resolve_type_bases(model, primitives, file, diagnostics)
40
+
41
+ for enum in model.enums:
42
+ diagnostics.extend(_validate_enum(enum, file))
43
+
44
+ referenced: set[str] = set()
45
+ for entity in model.entities:
46
+ diagnostics.extend(
47
+ _validate_entity(entity, model, primitives, type_bases, referenced, file)
48
+ )
49
+
50
+ for custom_type in model.types:
51
+ referenced.add(custom_type.base)
52
+ for enum in model.enums:
53
+ if enum.name not in referenced:
54
+ diagnostics.append(
55
+ warning(
56
+ "EG013",
57
+ f"enum {enum.name} is not used",
58
+ file=file,
59
+ entity=enum.name,
60
+ )
61
+ )
62
+ for custom_type in model.types:
63
+ if custom_type.name not in referenced:
64
+ diagnostics.append(
65
+ warning(
66
+ "EG013",
67
+ f"type {custom_type.name} is not used",
68
+ file=file,
69
+ entity=custom_type.name,
70
+ )
71
+ )
72
+ return diagnostics
73
+
74
+
75
+ def validate_document(document: ModelDocument) -> list[Diagnostic]:
76
+ return validate_entity_model(document.model, file=document.path)
77
+
78
+
79
+ def _validate_duplicate_names(
80
+ model: SourceModel | ModelSlice,
81
+ file: Path | None,
82
+ ) -> list[Diagnostic]:
83
+ diagnostics: list[Diagnostic] = []
84
+ buckets = (
85
+ ("entity", [item.name for item in model.entities]),
86
+ ("enum", [item.name for item in model.enums]),
87
+ ("type", [item.name for item in model.types]),
88
+ )
89
+ seen: dict[str, str] = {}
90
+ for kind, names in buckets:
91
+ local: set[str] = set()
92
+ for index, name in enumerate(names):
93
+ if name in local:
94
+ diagnostics.append(
95
+ error(
96
+ "EG002",
97
+ f"duplicate {kind} name: {name}",
98
+ file=file,
99
+ path=f"{kind}[{index}]",
100
+ entity=name,
101
+ )
102
+ )
103
+ local.add(name)
104
+ previous = seen.get(name)
105
+ if previous is not None and previous != kind:
106
+ diagnostics.append(
107
+ error(
108
+ "EG002",
109
+ f"name {name} is used both as {previous} and as {kind}",
110
+ file=file,
111
+ path=f"{kind}[{index}]",
112
+ entity=name,
113
+ )
114
+ )
115
+ else:
116
+ seen[name] = kind
117
+ return diagnostics
118
+
119
+
120
+ def _validate_case_collisions(
121
+ model: SourceModel | ModelSlice,
122
+ file: Path | None,
123
+ ) -> list[Diagnostic]:
124
+ diagnostics: list[Diagnostic] = []
125
+ names = [item.name for item in (*model.entities, *model.enums, *model.types)]
126
+ by_lower: dict[str, str] = {}
127
+ for name in names:
128
+ lower = name.lower()
129
+ previous = by_lower.get(lower)
130
+ if previous is not None and previous != name:
131
+ diagnostics.append(
132
+ warning(
133
+ "EG026",
134
+ f"case-insensitive name collision: {previous} and {name}",
135
+ file=file,
136
+ entity=name,
137
+ )
138
+ )
139
+ else:
140
+ by_lower[lower] = name
141
+ return diagnostics
142
+
143
+
144
+ def _resolve_type_bases(
145
+ model: SourceModel | ModelSlice,
146
+ primitives: set[str],
147
+ file: Path | None,
148
+ diagnostics: list[Diagnostic],
149
+ ) -> dict[str, str]:
150
+ resolved: dict[str, str] = {}
151
+ type_map = {item.name: item for item in model.types}
152
+ enum_names = {item.name for item in model.enums}
153
+
154
+ def resolve(custom_type: CustomType, stack: tuple[str, ...]) -> str | None:
155
+ if custom_type.name in stack:
156
+ diagnostics.append(
157
+ error(
158
+ "EG020",
159
+ f"CustomType.base cycle: {' -> '.join((*stack, custom_type.name))}",
160
+ file=file,
161
+ entity=custom_type.name,
162
+ )
163
+ )
164
+ return None
165
+ if len(stack) > MAX_TYPE_DEPTH:
166
+ diagnostics.append(
167
+ error(
168
+ "EG020",
169
+ f"CustomType.base depth exceeds {MAX_TYPE_DEPTH}",
170
+ file=file,
171
+ entity=custom_type.name,
172
+ )
173
+ )
174
+ return None
175
+ base = custom_type.base
176
+ if base in primitives:
177
+ return base
178
+ if base in enum_names:
179
+ diagnostics.append(
180
+ error(
181
+ "EG004",
182
+ f"CustomType.base cannot reference an enum: {base}",
183
+ file=file,
184
+ entity=custom_type.name,
185
+ )
186
+ )
187
+ return None
188
+ parent = type_map.get(base)
189
+ if parent is None:
190
+ diagnostics.append(
191
+ error(
192
+ "EG004",
193
+ f"unknown base type {base} for {custom_type.name}",
194
+ file=file,
195
+ entity=custom_type.name,
196
+ )
197
+ )
198
+ return None
199
+ return resolve(parent, (*stack, custom_type.name))
200
+
201
+ for custom_type in model.types:
202
+ base = resolve(custom_type, ())
203
+ if base is not None:
204
+ resolved[custom_type.name] = base
205
+ return resolved
206
+
207
+
208
+ def _validate_enum(enum: EnumDef, file: Path | None) -> list[Diagnostic]:
209
+ diagnostics: list[Diagnostic] = []
210
+ if not enum.values:
211
+ diagnostics.append(
212
+ error("EG019", f"enum {enum.name} is empty", file=file, entity=enum.name)
213
+ )
214
+ names: set[str] = set()
215
+ values: set[tuple[str, Any]] = set()
216
+ for index, item in enumerate(enum.values):
217
+ if item.name in names:
218
+ diagnostics.append(
219
+ error(
220
+ "EG019",
221
+ f"duplicate value name in enum {enum.name}: {item.name}",
222
+ file=file,
223
+ entity=enum.name,
224
+ field=item.name,
225
+ path=f"enums[{index}]",
226
+ )
227
+ )
228
+ names.add(item.name)
229
+ value = item.value if item.value is not None else item.name
230
+ marker = (type(value).__name__, value)
231
+ if marker in values:
232
+ diagnostics.append(
233
+ error(
234
+ "EG019",
235
+ f"duplicate value in enum {enum.name}: {value!r}",
236
+ file=file,
237
+ entity=enum.name,
238
+ field=item.name,
239
+ path=f"enums[{index}]",
240
+ )
241
+ )
242
+ values.add(marker)
243
+ return diagnostics
244
+
245
+
246
+ def _validate_entity(
247
+ entity: Entity,
248
+ model: SourceModel | ModelSlice,
249
+ primitives: set[str],
250
+ type_bases: dict[str, str],
251
+ referenced: set[str],
252
+ file: Path | None,
253
+ ) -> list[Diagnostic]:
254
+ diagnostics: list[Diagnostic] = []
255
+ if not re.fullmatch(r"[A-Z][A-Za-z0-9]*", entity.name):
256
+ diagnostics.append(
257
+ warning(
258
+ "EG012",
259
+ f"entity {entity.name} is not PascalCase",
260
+ file=file,
261
+ entity=entity.name,
262
+ )
263
+ )
264
+ if len({field.name for field in entity.fields}) != len(entity.fields):
265
+ seen: set[str] = set()
266
+ for index, field in enumerate(entity.fields):
267
+ if field.name in seen:
268
+ diagnostics.append(
269
+ error(
270
+ "EG003",
271
+ f"duplicate field {field.name} in {entity.name}",
272
+ file=file,
273
+ entity=entity.name,
274
+ field=field.name,
275
+ path=f"entities[{entity.name}].fields[{index}]",
276
+ )
277
+ )
278
+ seen.add(field.name)
279
+
280
+ enum_map = {item.name: item for item in model.enums}
281
+ type_map = {item.name: item for item in model.types}
282
+ for index, field in enumerate(entity.fields):
283
+ location = {
284
+ "file": file,
285
+ "entity": entity.name,
286
+ "field": field.name,
287
+ "path": f"entities[{entity.name}].fields[{index}]",
288
+ }
289
+ if field.type in enum_map or field.type in type_map:
290
+ referenced.add(field.type)
291
+ elif field.type in primitives:
292
+ pass
293
+ else:
294
+ diagnostics.append(error("EG004", f"unknown type {field.type}", **location))
295
+
296
+ if field.default is None and not field.nullable:
297
+ diagnostics.append(
298
+ error(
299
+ "EG010",
300
+ "default: null is not allowed when nullable: false",
301
+ **location,
302
+ )
303
+ )
304
+ if field.required and field.nullable:
305
+ diagnostics.append(
306
+ warning(
307
+ "EG011",
308
+ "required: true and nullable: true is valid only for PATCH",
309
+ **location,
310
+ )
311
+ )
312
+ if field.required and not field.description:
313
+ diagnostics.append(
314
+ warning(
315
+ "EG015",
316
+ f"required field {field.name} has no description",
317
+ **location,
318
+ )
319
+ )
320
+
321
+ effective = type_bases.get(field.type, field.type)
322
+ constraints = _merge_constraints(field, type_map.get(field.type))
323
+ diagnostics.extend(_validate_constraints(constraints, effective, location))
324
+ if field.default is not UNSET and field.default is not None:
325
+ diagnostics.extend(
326
+ _validate_default(
327
+ field.default,
328
+ effective,
329
+ enum_map.get(field.type),
330
+ constraints,
331
+ location,
332
+ )
333
+ )
334
+ return diagnostics
335
+
336
+
337
+ def _merge_constraints(
338
+ field: FieldDef,
339
+ custom_type: CustomType | None,
340
+ ) -> Constraints | None:
341
+ if field.constraints is None:
342
+ return custom_type.constraints if custom_type is not None else None
343
+ if custom_type is None or custom_type.constraints is None:
344
+ return field.constraints
345
+
346
+ base = custom_type.constraints
347
+ override = field.constraints
348
+ values: dict[str, Any] = {}
349
+ for name in ("min", "max", "min_length", "max_length", "pattern"):
350
+ if name in override.model_fields_set:
351
+ values[name] = getattr(override, name)
352
+ else:
353
+ values[name] = getattr(base, name)
354
+ return Constraints(**values)
355
+
356
+
357
+ def _validate_constraints(
358
+ constraints: Constraints | None,
359
+ base_type: str,
360
+ location: dict[str, Any],
361
+ ) -> list[Diagnostic]:
362
+ if constraints is None:
363
+ return []
364
+ diagnostics: list[Diagnostic] = []
365
+ string_like = base_type in {"string", "text", "uuid"}
366
+ numeric = base_type in {"integer", "bigint", "float"}
367
+ temporal = base_type in {"date", "datetime"}
368
+
369
+ if constraints.pattern is not None and not string_like:
370
+ diagnostics.append(error("EG021", "pattern applies only to string types", **location))
371
+ if (constraints.min_length is not None or constraints.max_length is not None) and not (
372
+ string_like
373
+ ):
374
+ diagnostics.append(
375
+ error(
376
+ "EG021",
377
+ "min_length/max_length apply only to string types",
378
+ **location,
379
+ )
380
+ )
381
+ if (constraints.min is not None or constraints.max is not None) and not (numeric or temporal):
382
+ diagnostics.append(error("EG021", "min/max apply only to numbers and dates", **location))
383
+ if (
384
+ constraints.min is not None
385
+ and constraints.max is not None
386
+ and _safe_compare(constraints.min, constraints.max) > 0
387
+ ):
388
+ diagnostics.append(error("EG021", "min is greater than max", **location))
389
+ if (
390
+ constraints.min_length is not None
391
+ and constraints.max_length is not None
392
+ and constraints.min_length > constraints.max_length
393
+ ):
394
+ diagnostics.append(error("EG021", "min_length is greater than max_length", **location))
395
+ if constraints.pattern is not None:
396
+ try:
397
+ re.compile(constraints.pattern)
398
+ except re.error as exc:
399
+ diagnostics.append(error("EG005", f"invalid regex: {exc}", **location))
400
+ return diagnostics
401
+
402
+
403
+ def _validate_default(
404
+ default: object,
405
+ base_type: str,
406
+ enum: EnumDef | None,
407
+ constraints: Constraints | None,
408
+ location: dict[str, Any],
409
+ ) -> list[Diagnostic]:
410
+ diagnostics: list[Diagnostic] = []
411
+ if enum is not None:
412
+ values = {item.value if item.value is not None else item.name for item in enum.values}
413
+ names = {item.name for item in enum.values}
414
+ if default not in values and default not in names:
415
+ diagnostics.append(
416
+ error(
417
+ "EG017",
418
+ f"default {default!r} is not in enum {enum.name}",
419
+ **location,
420
+ )
421
+ )
422
+ return diagnostics
423
+
424
+ valid = _default_matches_type(default, base_type)
425
+ if not valid:
426
+ diagnostics.append(
427
+ error(
428
+ "EG016",
429
+ f"default {default!r} does not match type {base_type}",
430
+ **location,
431
+ )
432
+ )
433
+ return diagnostics
434
+ if constraints is not None:
435
+ diagnostics.extend(_validate_default_constraints(default, constraints, location))
436
+ return diagnostics
437
+
438
+
439
+ def _default_matches_type(value: object, base_type: str) -> bool:
440
+ if base_type in {"string", "text"}:
441
+ return isinstance(value, str)
442
+ if base_type == "uuid":
443
+ return isinstance(value, str)
444
+ if base_type in {"integer", "bigint"}:
445
+ return isinstance(value, int) and not isinstance(value, bool)
446
+ if base_type == "boolean":
447
+ return isinstance(value, bool)
448
+ if base_type == "float":
449
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
450
+ if base_type == "date":
451
+ return isinstance(value, date) and not isinstance(value, datetime)
452
+ if base_type == "datetime":
453
+ return isinstance(value, datetime)
454
+ return True
455
+
456
+
457
+ def _validate_default_constraints(
458
+ value: object,
459
+ constraints: Constraints,
460
+ location: dict[str, Any],
461
+ ) -> list[Diagnostic]:
462
+ diagnostics: list[Diagnostic] = []
463
+ try:
464
+ if constraints.min is not None and _safe_compare(value, constraints.min) < 0:
465
+ diagnostics.append(
466
+ error(
467
+ "EG017", f"default {value!r} is less than min {constraints.min!r}", **location
468
+ )
469
+ )
470
+ if constraints.max is not None and _safe_compare(value, constraints.max) > 0:
471
+ diagnostics.append(
472
+ error(
473
+ "EG017",
474
+ f"default {value!r} is greater than max {constraints.max!r}",
475
+ **location,
476
+ )
477
+ )
478
+ except TypeError:
479
+ diagnostics.append(
480
+ error("EG017", f"default {value!r} cannot be compared with min/max", **location)
481
+ )
482
+ if isinstance(value, str):
483
+ if constraints.min_length is not None and len(value) < constraints.min_length:
484
+ diagnostics.append(
485
+ error(
486
+ "EG017",
487
+ f"default is shorter than min_length {constraints.min_length}",
488
+ **location,
489
+ )
490
+ )
491
+ if constraints.max_length is not None and len(value) > constraints.max_length:
492
+ diagnostics.append(
493
+ error(
494
+ "EG017",
495
+ f"default is longer than max_length {constraints.max_length}",
496
+ **location,
497
+ )
498
+ )
499
+ if constraints.pattern is not None and not re.fullmatch(constraints.pattern, value):
500
+ diagnostics.append(
501
+ error(
502
+ "EG017",
503
+ f"default does not match pattern {constraints.pattern!r}",
504
+ **location,
505
+ )
506
+ )
507
+ return diagnostics
508
+
509
+
510
+ def _safe_compare(left: object, right: object) -> int:
511
+ if left < right: # type: ignore[operator]
512
+ return -1
513
+ if left > right: # type: ignore[operator]
514
+ return 1
515
+ return 0
516
+
517
+
518
+ __all__ = ["validate_document", "validate_entity_model"]
File without changes