fastapi-breakgate 1.0.1__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,779 @@
1
+ """Pure-Python OpenAPI breaking-change classifier (production ruleset)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+ from fastapi_contract.diff.policy import ChangeKind, severity_for
10
+ from fastapi_contract.models import PolicyName
11
+
12
+ HTTP_METHODS = ("get", "post", "put", "patch", "delete", "options", "head", "trace")
13
+
14
+
15
+ class ChangeSeverity(str, Enum):
16
+ BREAKING = "breaking"
17
+ RISKY = "risky"
18
+ SAFE = "safe"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Change:
23
+ kind: ChangeKind
24
+ severity: ChangeSeverity
25
+ path: str
26
+ message: str
27
+ method: str | None = None
28
+ details: dict[str, Any] = field(default_factory=dict)
29
+
30
+ def to_dict(self) -> dict[str, Any]:
31
+ return {
32
+ "kind": self.kind.value,
33
+ "severity": self.severity.value,
34
+ "path": self.path,
35
+ "method": self.method,
36
+ "message": self.message,
37
+ "details": self.details,
38
+ }
39
+
40
+
41
+ @dataclass
42
+ class DiffResult:
43
+ changes: list[Change]
44
+ base_paths: int
45
+ candidate_paths: int
46
+ engine: str = "python"
47
+
48
+ @property
49
+ def breaking(self) -> list[Change]:
50
+ return [c for c in self.changes if c.severity == ChangeSeverity.BREAKING]
51
+
52
+ @property
53
+ def risky(self) -> list[Change]:
54
+ return [c for c in self.changes if c.severity == ChangeSeverity.RISKY]
55
+
56
+ @property
57
+ def safe(self) -> list[Change]:
58
+ return [c for c in self.changes if c.severity == ChangeSeverity.SAFE]
59
+
60
+ def has_failing(self, fail_on: str) -> bool:
61
+ if fail_on == "any":
62
+ return bool(self.breaking or self.risky or self.safe)
63
+ if fail_on == "risky":
64
+ return bool(self.breaking or self.risky)
65
+ return bool(self.breaking)
66
+
67
+
68
+ def _sev(kind: ChangeKind, policy: PolicyName) -> ChangeSeverity:
69
+ return ChangeSeverity(severity_for(kind, policy))
70
+
71
+
72
+ def _change(
73
+ kind: ChangeKind,
74
+ policy: PolicyName,
75
+ path: str,
76
+ message: str,
77
+ *,
78
+ method: str | None = None,
79
+ **details: Any,
80
+ ) -> Change:
81
+ return Change(
82
+ kind=kind,
83
+ severity=_sev(kind, policy),
84
+ path=path,
85
+ method=method,
86
+ message=message,
87
+ details=details,
88
+ )
89
+
90
+
91
+ def _paths(schema: dict[str, Any]) -> dict[str, Any]:
92
+ paths = schema.get("paths") or {}
93
+ return paths if isinstance(paths, dict) else {}
94
+
95
+
96
+ def _components_schemas(schema: dict[str, Any]) -> dict[str, Any]:
97
+ components = schema.get("components") or {}
98
+ if not isinstance(components, dict):
99
+ return {}
100
+ schemas = components.get("schemas") or {}
101
+ return schemas if isinstance(schemas, dict) else {}
102
+
103
+
104
+ def _resolve_ref(schema: dict[str, Any], node: Any, *, _seen: set[str] | None = None) -> Any:
105
+ if not isinstance(node, dict):
106
+ return node
107
+ ref = node.get("$ref")
108
+ if not isinstance(ref, str) or not ref.startswith("#/"):
109
+ return node
110
+ seen = _seen or set()
111
+ if ref in seen:
112
+ return node
113
+ seen.add(ref)
114
+ cur: Any = schema
115
+ for part in ref[2:].split("/"):
116
+ if not isinstance(cur, dict) or part not in cur:
117
+ return node
118
+ cur = cur[part]
119
+ if isinstance(cur, dict) and "$ref" in cur:
120
+ return _resolve_ref(schema, cur, _seen=seen)
121
+ return cur
122
+
123
+
124
+ def _schema_type(node: dict[str, Any]) -> str | None:
125
+ t = node.get("type")
126
+ if isinstance(t, list):
127
+ return ",".join(str(x) for x in t)
128
+ return str(t) if t is not None else None
129
+
130
+
131
+ def _enum_values(node: dict[str, Any]) -> set[Any] | None:
132
+ if "enum" not in node:
133
+ return None
134
+ values = node.get("enum")
135
+ if not isinstance(values, list):
136
+ return set()
137
+ return set(values)
138
+
139
+
140
+ def _properties(node: dict[str, Any]) -> dict[str, Any]:
141
+ props = node.get("properties") or {}
142
+ return props if isinstance(props, dict) else {}
143
+
144
+
145
+ def _required(node: dict[str, Any]) -> set[str]:
146
+ req = node.get("required") or []
147
+ if not isinstance(req, list):
148
+ return set()
149
+ return {str(x) for x in req}
150
+
151
+
152
+ def _get_request_body_schema(
153
+ root: dict[str, Any], operation: dict[str, Any]
154
+ ) -> dict[str, Any] | None:
155
+ body = operation.get("requestBody")
156
+ if not isinstance(body, dict):
157
+ return None
158
+ content = body.get("content") or {}
159
+ if not isinstance(content, dict):
160
+ return None
161
+ for media in ("application/json", "application/x-www-form-urlencoded", "multipart/form-data"):
162
+ if media in content and isinstance(content[media], dict):
163
+ schema = content[media].get("schema")
164
+ if isinstance(schema, dict):
165
+ resolved = _resolve_ref(root, schema)
166
+ return resolved if isinstance(resolved, dict) else None
167
+ # first available
168
+ for item in content.values():
169
+ if isinstance(item, dict) and isinstance(item.get("schema"), dict):
170
+ resolved = _resolve_ref(root, item["schema"])
171
+ return resolved if isinstance(resolved, dict) else None
172
+ return None
173
+
174
+
175
+ def _response_schemas(
176
+ root: dict[str, Any], operation: dict[str, Any]
177
+ ) -> dict[str, dict[str, Any]]:
178
+ result: dict[str, dict[str, Any]] = {}
179
+ responses = operation.get("responses") or {}
180
+ if not isinstance(responses, dict):
181
+ return result
182
+ for status, resp in responses.items():
183
+ if not isinstance(resp, dict):
184
+ continue
185
+ content = resp.get("content") or {}
186
+ if not isinstance(content, dict):
187
+ result[str(status)] = {}
188
+ continue
189
+ media = content.get("application/json") or next(iter(content.values()), None)
190
+ if isinstance(media, dict) and isinstance(media.get("schema"), dict):
191
+ resolved = _resolve_ref(root, media["schema"])
192
+ result[str(status)] = resolved if isinstance(resolved, dict) else {}
193
+ else:
194
+ result[str(status)] = {}
195
+ return result
196
+
197
+
198
+ def _compare_object_schema(
199
+ *,
200
+ base_root: dict[str, Any],
201
+ cand_root: dict[str, Any],
202
+ base: dict[str, Any],
203
+ cand: dict[str, Any],
204
+ policy: PolicyName,
205
+ path: str,
206
+ method: str,
207
+ location: str,
208
+ changes: list[Change],
209
+ ) -> None:
210
+ base_props = _properties(base)
211
+ cand_props = _properties(cand)
212
+ base_req = _required(base)
213
+ cand_req = _required(cand)
214
+
215
+ for name in sorted(base_props.keys() - cand_props.keys()):
216
+ kind = (
217
+ ChangeKind.RESPONSE_FIELD_REMOVED
218
+ if location == "response"
219
+ else ChangeKind.REQUEST_FIELD_REMOVED
220
+ )
221
+ changes.append(
222
+ _change(
223
+ kind,
224
+ policy,
225
+ path,
226
+ f"Removed {location} field '{name}'",
227
+ method=method,
228
+ field=name,
229
+ location=location,
230
+ )
231
+ )
232
+
233
+ for name in sorted(cand_props.keys() - base_props.keys()):
234
+ if location == "request":
235
+ if name in cand_req:
236
+ changes.append(
237
+ _change(
238
+ ChangeKind.REQUIRED_REQUEST_FIELD_ADDED,
239
+ policy,
240
+ path,
241
+ f"Added required request field '{name}'",
242
+ method=method,
243
+ field=name,
244
+ )
245
+ )
246
+ else:
247
+ changes.append(
248
+ Change(
249
+ kind=ChangeKind.OTHER,
250
+ severity=ChangeSeverity.SAFE,
251
+ path=path,
252
+ method=method,
253
+ message=f"Added optional request field '{name}'",
254
+ details={"field": name, "location": "request"},
255
+ )
256
+ )
257
+ else:
258
+ if name in cand_req and name not in base_req:
259
+ changes.append(
260
+ _change(
261
+ ChangeKind.RESPONSE_REQUIRED_FIELD_ADDED,
262
+ policy,
263
+ path,
264
+ f"Added required response field '{name}'",
265
+ method=method,
266
+ field=name,
267
+ )
268
+ )
269
+ else:
270
+ changes.append(
271
+ _change(
272
+ ChangeKind.RESPONSE_FIELD_ADDED,
273
+ policy,
274
+ path,
275
+ f"Added response field '{name}'",
276
+ method=method,
277
+ field=name,
278
+ )
279
+ )
280
+
281
+ for name in sorted(cand_req - base_req):
282
+ if name in base_props and location == "request":
283
+ changes.append(
284
+ _change(
285
+ ChangeKind.REQUEST_FIELD_OPTIONAL_TO_REQUIRED,
286
+ policy,
287
+ path,
288
+ f"Request field '{name}' became required",
289
+ method=method,
290
+ field=name,
291
+ )
292
+ )
293
+
294
+ for name in sorted(base_req - cand_req):
295
+ if name in cand_props and location == "request":
296
+ changes.append(
297
+ _change(
298
+ ChangeKind.REQUEST_FIELD_REQUIRED_TO_OPTIONAL,
299
+ policy,
300
+ path,
301
+ f"Request field '{name}' became optional",
302
+ method=method,
303
+ field=name,
304
+ )
305
+ )
306
+
307
+ for name in sorted(base_props.keys() & cand_props.keys()):
308
+ b = base_props[name]
309
+ c = cand_props[name]
310
+ if not isinstance(b, dict) or not isinstance(c, dict):
311
+ continue
312
+ _compare_schema_nodes(
313
+ base_root=base_root,
314
+ cand_root=cand_root,
315
+ base=b,
316
+ cand=c,
317
+ policy=policy,
318
+ path=path,
319
+ method=method,
320
+ location=f"{location}.{name}",
321
+ changes=changes,
322
+ field_name=name,
323
+ )
324
+
325
+
326
+ def _compare_schema_nodes(
327
+ *,
328
+ base_root: dict[str, Any],
329
+ cand_root: dict[str, Any],
330
+ base: dict[str, Any],
331
+ cand: dict[str, Any],
332
+ policy: PolicyName,
333
+ path: str,
334
+ method: str,
335
+ location: str,
336
+ changes: list[Change],
337
+ field_name: str | None = None,
338
+ ) -> None:
339
+ b = _resolve_ref(base_root, base) if base_root else base
340
+ c = _resolve_ref(cand_root, cand) if cand_root else cand
341
+ if not isinstance(b, dict) or not isinstance(c, dict):
342
+ return
343
+
344
+ bt = _schema_type(b)
345
+ ct = _schema_type(c)
346
+ if bt and ct and bt != ct:
347
+ # narrowing / widening heuristics
348
+ widen_pairs = {("integer", "number"), ("number", "string")}
349
+ if (bt, ct) in widen_pairs:
350
+ kind = ChangeKind.TYPE_WIDENED
351
+ elif (ct, bt) in widen_pairs:
352
+ kind = ChangeKind.TYPE_NARROWED
353
+ else:
354
+ kind = ChangeKind.TYPE_CHANGED
355
+ label = field_name or location
356
+ changes.append(
357
+ _change(
358
+ kind,
359
+ policy,
360
+ path,
361
+ f"Type of '{label}' changed from {bt} to {ct}",
362
+ method=method,
363
+ field=field_name,
364
+ from_type=bt,
365
+ to_type=ct,
366
+ )
367
+ )
368
+
369
+ be = _enum_values(b)
370
+ ce = _enum_values(c)
371
+ if be is not None and ce is not None:
372
+ removed = be - ce
373
+ added = ce - be
374
+ label = field_name or location
375
+ for value in sorted(removed, key=lambda x: str(x)):
376
+ changes.append(
377
+ _change(
378
+ ChangeKind.ENUM_VALUE_REMOVED,
379
+ policy,
380
+ path,
381
+ f"Enum value {value!r} removed from '{label}'",
382
+ method=method,
383
+ field=field_name,
384
+ value=value,
385
+ )
386
+ )
387
+ for value in sorted(added, key=lambda x: str(x)):
388
+ changes.append(
389
+ _change(
390
+ ChangeKind.ENUM_VALUE_ADDED,
391
+ policy,
392
+ path,
393
+ f"Enum value {value!r} added to '{label}'",
394
+ method=method,
395
+ field=field_name,
396
+ value=value,
397
+ )
398
+ )
399
+
400
+ if _properties(b) or _properties(c):
401
+ loc = "request"
402
+ if "response" in location:
403
+ loc = "response"
404
+ elif "request" in location:
405
+ loc = "request"
406
+ _compare_object_schema(
407
+ base_root=base_root,
408
+ cand_root=cand_root,
409
+ base=b,
410
+ cand=c,
411
+ policy=policy,
412
+ path=path,
413
+ method=method,
414
+ location=loc,
415
+ changes=changes,
416
+ )
417
+
418
+
419
+ def _compare_parameters(
420
+ *,
421
+ base_root: dict[str, Any],
422
+ cand_root: dict[str, Any],
423
+ base_op: dict[str, Any],
424
+ cand_op: dict[str, Any],
425
+ policy: PolicyName,
426
+ path: str,
427
+ method: str,
428
+ changes: list[Change],
429
+ ) -> None:
430
+ def index(op: dict[str, Any], root: dict[str, Any]) -> dict[tuple[str, str], dict[str, Any]]:
431
+ out: dict[tuple[str, str], dict[str, Any]] = {}
432
+ params = op.get("parameters") or []
433
+ if not isinstance(params, list):
434
+ return out
435
+ for raw in params:
436
+ if not isinstance(raw, dict):
437
+ continue
438
+ node = _resolve_ref(root, raw)
439
+ if not isinstance(node, dict):
440
+ continue
441
+ name = str(node.get("name", ""))
442
+ location = str(node.get("in", "query"))
443
+ if name:
444
+ out[(name, location)] = node
445
+ return out
446
+
447
+ b_params = index(base_op, base_root)
448
+ c_params = index(cand_op, cand_root)
449
+
450
+ for key in sorted(b_params.keys() - c_params.keys()):
451
+ name, location = key
452
+ changes.append(
453
+ _change(
454
+ ChangeKind.PARAMETER_REMOVED,
455
+ policy,
456
+ path,
457
+ f"Removed {location} parameter '{name}'",
458
+ method=method,
459
+ parameter=name,
460
+ location=location,
461
+ )
462
+ )
463
+
464
+ for key in sorted(c_params.keys() - b_params.keys()):
465
+ name, location = key
466
+ node = c_params[key]
467
+ required = bool(node.get("required"))
468
+ kind = (
469
+ ChangeKind.PARAMETER_ADDED_REQUIRED
470
+ if required
471
+ else ChangeKind.PARAMETER_ADDED_OPTIONAL
472
+ )
473
+ changes.append(
474
+ _change(
475
+ kind,
476
+ policy,
477
+ path,
478
+ f"Added {'required ' if required else ''}{location} parameter '{name}'",
479
+ method=method,
480
+ parameter=name,
481
+ location=location,
482
+ )
483
+ )
484
+
485
+ for key in sorted(b_params.keys() & c_params.keys()):
486
+ b = b_params[key]
487
+ c = c_params[key]
488
+ if not bool(b.get("required")) and bool(c.get("required")):
489
+ name, location = key
490
+ changes.append(
491
+ _change(
492
+ ChangeKind.PARAMETER_BECAME_REQUIRED,
493
+ policy,
494
+ path,
495
+ f"{location} parameter '{name}' became required",
496
+ method=method,
497
+ parameter=name,
498
+ )
499
+ )
500
+ b_schema = b.get("schema") if isinstance(b.get("schema"), dict) else {}
501
+ c_schema = c.get("schema") if isinstance(c.get("schema"), dict) else {}
502
+ if b_schema and c_schema:
503
+ _compare_schema_nodes(
504
+ base_root=base_root,
505
+ cand_root=cand_root,
506
+ base=b_schema,
507
+ cand=c_schema,
508
+ policy=policy,
509
+ path=path,
510
+ method=method,
511
+ location=f"parameter.{key[0]}",
512
+ changes=changes,
513
+ field_name=key[0],
514
+ )
515
+
516
+
517
+ def _compare_security(
518
+ base_op: dict[str, Any],
519
+ cand_op: dict[str, Any],
520
+ policy: PolicyName,
521
+ path: str,
522
+ method: str,
523
+ changes: list[Change],
524
+ ) -> None:
525
+ base_sec = base_op.get("security")
526
+ cand_sec = cand_op.get("security")
527
+ if not base_sec and cand_sec:
528
+ changes.append(
529
+ _change(
530
+ ChangeKind.SECURITY_ADDED,
531
+ policy,
532
+ path,
533
+ "Operation now requires security",
534
+ method=method,
535
+ )
536
+ )
537
+
538
+
539
+ def _compare_operation(
540
+ *,
541
+ base_root: dict[str, Any],
542
+ cand_root: dict[str, Any],
543
+ base_op: dict[str, Any],
544
+ cand_op: dict[str, Any],
545
+ policy: PolicyName,
546
+ path: str,
547
+ method: str,
548
+ changes: list[Change],
549
+ ) -> None:
550
+ if not base_op.get("deprecated") and cand_op.get("deprecated"):
551
+ changes.append(
552
+ _change(
553
+ ChangeKind.OPERATION_DEPRECATED,
554
+ policy,
555
+ path,
556
+ "Operation marked deprecated",
557
+ method=method,
558
+ )
559
+ )
560
+
561
+ _compare_parameters(
562
+ base_root=base_root,
563
+ cand_root=cand_root,
564
+ base_op=base_op,
565
+ cand_op=cand_op,
566
+ policy=policy,
567
+ path=path,
568
+ method=method,
569
+ changes=changes,
570
+ )
571
+ _compare_security(base_op, cand_op, policy, path, method, changes)
572
+
573
+ base_body = _get_request_body_schema(base_root, base_op)
574
+ cand_body = _get_request_body_schema(cand_root, cand_op)
575
+ if base_body is not None and cand_body is not None:
576
+ _compare_schema_nodes(
577
+ base_root=base_root,
578
+ cand_root=cand_root,
579
+ base=base_body,
580
+ cand=cand_body,
581
+ policy=policy,
582
+ path=path,
583
+ method=method,
584
+ location="request",
585
+ changes=changes,
586
+ )
587
+ elif base_body is not None and cand_body is None:
588
+ changes.append(
589
+ _change(
590
+ ChangeKind.MEDIA_TYPE_REMOVED,
591
+ policy,
592
+ path,
593
+ "Request body removed",
594
+ method=method,
595
+ )
596
+ )
597
+
598
+ base_resps = _response_schemas(base_root, base_op)
599
+ cand_resps = _response_schemas(cand_root, cand_op)
600
+ for status in sorted(set(base_resps) - set(cand_resps)):
601
+ # ignore default-only noise somewhat
602
+ changes.append(
603
+ _change(
604
+ ChangeKind.RESPONSE_STATUS_REMOVED,
605
+ policy,
606
+ path,
607
+ f"Response status {status} removed",
608
+ method=method,
609
+ status=status,
610
+ )
611
+ )
612
+ for status in sorted(set(cand_resps) - set(base_resps)):
613
+ changes.append(
614
+ _change(
615
+ ChangeKind.RESPONSE_STATUS_ADDED,
616
+ policy,
617
+ path,
618
+ f"Response status {status} added",
619
+ method=method,
620
+ status=status,
621
+ )
622
+ )
623
+ for status in sorted(set(base_resps) & set(cand_resps)):
624
+ b_schema = base_resps[status]
625
+ c_schema = cand_resps[status]
626
+ if b_schema or c_schema:
627
+ _compare_schema_nodes(
628
+ base_root=base_root,
629
+ cand_root=cand_root,
630
+ base=b_schema or {},
631
+ cand=c_schema or {},
632
+ policy=policy,
633
+ path=path,
634
+ method=method,
635
+ location=f"response.{status}",
636
+ changes=changes,
637
+ )
638
+
639
+
640
+ def diff_openapi(
641
+ base: dict[str, Any],
642
+ candidate: dict[str, Any],
643
+ *,
644
+ policy: PolicyName = PolicyName.ADDITIVE_FRIENDLY,
645
+ ) -> DiffResult:
646
+ """Compare two OpenAPI documents and classify changes."""
647
+ changes: list[Change] = []
648
+ base_paths = _paths(base)
649
+ cand_paths = _paths(candidate)
650
+
651
+ for path in sorted(set(base_paths) - set(cand_paths)):
652
+ changes.append(
653
+ _change(
654
+ ChangeKind.PATH_REMOVED,
655
+ policy,
656
+ path,
657
+ f"Path removed: {path}",
658
+ )
659
+ )
660
+ for path in sorted(set(cand_paths) - set(base_paths)):
661
+ changes.append(
662
+ _change(
663
+ ChangeKind.PATH_ADDED,
664
+ policy,
665
+ path,
666
+ f"Path added: {path}",
667
+ )
668
+ )
669
+
670
+ for path in sorted(set(base_paths) & set(cand_paths)):
671
+ b_item = base_paths[path] if isinstance(base_paths[path], dict) else {}
672
+ c_item = cand_paths[path] if isinstance(cand_paths[path], dict) else {}
673
+ for method in HTTP_METHODS:
674
+ b_op = b_item.get(method)
675
+ c_op = c_item.get(method)
676
+ b_ok = isinstance(b_op, dict)
677
+ c_ok = isinstance(c_op, dict)
678
+ if b_ok and not c_ok:
679
+ changes.append(
680
+ _change(
681
+ ChangeKind.OPERATION_REMOVED,
682
+ policy,
683
+ path,
684
+ f"Operation removed: {method.upper()} {path}",
685
+ method=method,
686
+ )
687
+ )
688
+ elif c_ok and not b_ok:
689
+ changes.append(
690
+ _change(
691
+ ChangeKind.OPERATION_ADDED,
692
+ policy,
693
+ path,
694
+ f"Operation added: {method.upper()} {path}",
695
+ method=method,
696
+ )
697
+ )
698
+ elif b_ok and c_ok:
699
+ _compare_operation(
700
+ base_root=base,
701
+ cand_root=candidate,
702
+ base_op=b_op,
703
+ cand_op=c_op,
704
+ policy=policy,
705
+ path=path,
706
+ method=method,
707
+ changes=changes,
708
+ )
709
+
710
+ # Component schema removals / enum-level diffs (FastAPI often uses $ref enums)
711
+ base_schemas = _components_schemas(base)
712
+ cand_schemas = _components_schemas(candidate)
713
+ for name in sorted(set(base_schemas) - set(cand_schemas)):
714
+ changes.append(
715
+ _change(
716
+ ChangeKind.SCHEMA_REMOVED,
717
+ policy,
718
+ f"#/components/schemas/{name}",
719
+ f"Component schema removed: {name}",
720
+ )
721
+ )
722
+ for name in sorted(set(base_schemas) & set(cand_schemas)):
723
+ b_node = base_schemas[name]
724
+ c_node = cand_schemas[name]
725
+ if not isinstance(b_node, dict) or not isinstance(c_node, dict):
726
+ continue
727
+ be = _enum_values(b_node)
728
+ ce = _enum_values(c_node)
729
+ if be is not None and ce is not None:
730
+ for value in sorted(be - ce, key=lambda x: str(x)):
731
+ changes.append(
732
+ _change(
733
+ ChangeKind.ENUM_VALUE_REMOVED,
734
+ policy,
735
+ f"#/components/schemas/{name}",
736
+ f"Enum value {value!r} removed from '{name}'",
737
+ value=value,
738
+ )
739
+ )
740
+ for value in sorted(ce - be, key=lambda x: str(x)):
741
+ changes.append(
742
+ _change(
743
+ ChangeKind.ENUM_VALUE_ADDED,
744
+ policy,
745
+ f"#/components/schemas/{name}",
746
+ f"Enum value {value!r} added to '{name}'",
747
+ value=value,
748
+ )
749
+ )
750
+ bt = _schema_type(b_node)
751
+ ct = _schema_type(c_node)
752
+ if bt and ct and bt != ct:
753
+ changes.append(
754
+ _change(
755
+ ChangeKind.TYPE_CHANGED,
756
+ policy,
757
+ f"#/components/schemas/{name}",
758
+ f"Type of '{name}' changed from {bt} to {ct}",
759
+ from_type=bt,
760
+ to_type=ct,
761
+ )
762
+ )
763
+
764
+ # Deduplicate identical messages
765
+ unique: list[Change] = []
766
+ seen: set[tuple[Any, ...]] = set()
767
+ for change in changes:
768
+ key = (change.kind, change.severity, change.path, change.method, change.message)
769
+ if key in seen:
770
+ continue
771
+ seen.add(key)
772
+ unique.append(change)
773
+
774
+ return DiffResult(
775
+ changes=unique,
776
+ base_paths=len(base_paths),
777
+ candidate_paths=len(cand_paths),
778
+ engine="python",
779
+ )