specshift 1.0.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.
specshift/differ.py ADDED
@@ -0,0 +1,721 @@
1
+ """
2
+ Core diff engine that compares two OpenAPI/Swagger specifications and
3
+ classifies each change as breaking, warning, or info.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Optional
9
+
10
+ from specshift.models import Change, ChangeType, DiffResult, Severity
11
+ from specshift.spec_loader import resolve_schema
12
+
13
+ HTTP_METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
14
+
15
+
16
+ def diff_specs(old_spec: dict, new_spec: dict) -> DiffResult:
17
+ """Compares two specifications and returns a DiffResult."""
18
+ result = DiffResult(
19
+ old_title=_get_info(old_spec, "title"),
20
+ new_title=_get_info(new_spec, "title"),
21
+ old_version=_get_info(old_spec, "version"),
22
+ new_version=_get_info(new_spec, "version"),
23
+ )
24
+
25
+ _diff_paths(old_spec, new_spec, result)
26
+ _diff_servers(old_spec, new_spec, result)
27
+ _diff_global_security(old_spec, new_spec, result)
28
+
29
+ return result
30
+
31
+
32
+ def _get_info(spec: dict, key: str) -> str:
33
+ return str(spec.get("info", {}).get(key, "")) if isinstance(spec, dict) else ""
34
+
35
+
36
+ def _diff_servers(old_spec: dict, new_spec: dict, result: DiffResult) -> None:
37
+ old_servers = {s.get("url") for s in old_spec.get("servers", []) if isinstance(s, dict)}
38
+ new_servers = {s.get("url") for s in new_spec.get("servers", []) if isinstance(s, dict)}
39
+
40
+ removed = old_servers - new_servers
41
+ added = new_servers - old_servers
42
+
43
+ for url in removed:
44
+ result.add(
45
+ Change(
46
+ severity=Severity.WARNING,
47
+ change_type=ChangeType.REMOVED,
48
+ location="servers",
49
+ message=f"Server URL removed: {url}",
50
+ old_value=url,
51
+ category="server",
52
+ )
53
+ )
54
+ for url in added:
55
+ result.add(
56
+ Change(
57
+ severity=Severity.INFO,
58
+ change_type=ChangeType.ADDED,
59
+ location="servers",
60
+ message=f"New server URL added: {url}",
61
+ new_value=url,
62
+ category="server",
63
+ )
64
+ )
65
+
66
+
67
+ def _diff_global_security(old_spec: dict, new_spec: dict, result: DiffResult) -> None:
68
+ old_sec = old_spec.get("security", [])
69
+ new_sec = new_spec.get("security", [])
70
+
71
+ if not old_sec and new_sec:
72
+ result.add(
73
+ Change(
74
+ severity=Severity.BREAKING,
75
+ change_type=ChangeType.ADDED,
76
+ location="security (global)",
77
+ message="Authentication is now required API-wide where it previously was not.",
78
+ new_value=new_sec,
79
+ category="security",
80
+ )
81
+ )
82
+ elif old_sec and not new_sec:
83
+ result.add(
84
+ Change(
85
+ severity=Severity.WARNING,
86
+ change_type=ChangeType.REMOVED,
87
+ location="security (global)",
88
+ message="The API-wide mandatory authentication requirement has been removed.",
89
+ old_value=old_sec,
90
+ category="security",
91
+ )
92
+ )
93
+
94
+
95
+ def _diff_paths(old_spec: dict, new_spec: dict, result: DiffResult) -> None:
96
+ old_paths: dict = old_spec.get("paths", {}) or {}
97
+ new_paths: dict = new_spec.get("paths", {}) or {}
98
+
99
+ for path in old_paths:
100
+ if path not in new_paths:
101
+ result.add(
102
+ Change(
103
+ severity=Severity.BREAKING,
104
+ change_type=ChangeType.REMOVED,
105
+ location=path,
106
+ message=f"Endpoint completely removed: {path}",
107
+ category="path",
108
+ )
109
+ )
110
+
111
+ for path in new_paths:
112
+ if path not in old_paths:
113
+ result.add(
114
+ Change(
115
+ severity=Severity.INFO,
116
+ change_type=ChangeType.ADDED,
117
+ location=path,
118
+ message=f"New endpoint added: {path}",
119
+ category="path",
120
+ )
121
+ )
122
+
123
+ for path in old_paths:
124
+ if path in new_paths:
125
+ _diff_operations(path, old_paths[path], new_paths[path], old_spec, new_spec, result)
126
+
127
+
128
+ def _diff_operations(
129
+ path: str,
130
+ old_path_item: dict,
131
+ new_path_item: dict,
132
+ old_spec: dict,
133
+ new_spec: dict,
134
+ result: DiffResult,
135
+ ) -> None:
136
+ old_methods = {m for m in HTTP_METHODS if m in old_path_item}
137
+ new_methods = {m for m in HTTP_METHODS if m in new_path_item}
138
+
139
+ for method in old_methods - new_methods:
140
+ result.add(
141
+ Change(
142
+ severity=Severity.BREAKING,
143
+ change_type=ChangeType.REMOVED,
144
+ location=f"{method.upper()} {path}",
145
+ message=f"HTTP method removed: {method.upper()} {path}",
146
+ category="operation",
147
+ )
148
+ )
149
+
150
+ for method in new_methods - old_methods:
151
+ result.add(
152
+ Change(
153
+ severity=Severity.INFO,
154
+ change_type=ChangeType.ADDED,
155
+ location=f"{method.upper()} {path}",
156
+ message=f"New HTTP method added: {method.upper()} {path}",
157
+ category="operation",
158
+ )
159
+ )
160
+
161
+ for method in old_methods & new_methods:
162
+ loc = f"{method.upper()} {path}"
163
+ old_op = old_path_item[method]
164
+ new_op = new_path_item[method]
165
+
166
+ if bool(old_op.get("deprecated")) is False and bool(new_op.get("deprecated")) is True:
167
+ result.add(
168
+ Change(
169
+ severity=Severity.WARNING,
170
+ change_type=ChangeType.MODIFIED,
171
+ location=loc,
172
+ message="Operation has been marked as deprecated.",
173
+ category="operation",
174
+ )
175
+ )
176
+
177
+ _diff_operation_security(loc, old_op, new_op, result)
178
+ _diff_parameters(loc, old_op, new_op, old_spec, new_spec, result)
179
+ _diff_request_body(loc, old_op, new_op, old_spec, new_spec, result)
180
+ _diff_responses(loc, old_op, new_op, old_spec, new_spec, result)
181
+
182
+
183
+ def _diff_operation_security(loc: str, old_op: dict, new_op: dict, result: DiffResult) -> None:
184
+ if "security" not in old_op and "security" not in new_op:
185
+ return
186
+
187
+ old_sec = old_op.get("security")
188
+ new_sec = new_op.get("security")
189
+
190
+ if not old_sec and new_sec:
191
+ result.add(
192
+ Change(
193
+ severity=Severity.BREAKING,
194
+ change_type=ChangeType.ADDED,
195
+ location=loc,
196
+ message="A new mandatory authentication requirement has been added to this operation.",
197
+ category="security",
198
+ )
199
+ )
200
+ elif old_sec and new_sec == []:
201
+ result.add(
202
+ Change(
203
+ severity=Severity.WARNING,
204
+ change_type=ChangeType.REMOVED,
205
+ location=loc,
206
+ message="The authentication requirement for this operation has been removed.",
207
+ category="security",
208
+ )
209
+ )
210
+
211
+
212
+ def _param_key(param: dict) -> tuple:
213
+ return (param.get("name"), param.get("in"))
214
+
215
+
216
+ def _collect_params(op: dict, root_spec: dict) -> dict:
217
+ params: dict = {}
218
+ for raw in op.get("parameters", []) or []:
219
+ resolved = resolve_schema(raw, root_spec) if "$ref" in raw else raw
220
+ if isinstance(resolved, dict) and "name" in resolved:
221
+ params[_param_key(resolved)] = resolved
222
+ return params
223
+
224
+
225
+ def _diff_parameters(
226
+ loc: str, old_op: dict, new_op: dict, old_spec: dict, new_spec: dict, result: DiffResult
227
+ ) -> None:
228
+ old_params = _collect_params(old_op, old_spec)
229
+ new_params = _collect_params(new_op, new_spec)
230
+
231
+ for key, param in old_params.items():
232
+ name, location_in = key
233
+ if key not in new_params:
234
+ required = bool(param.get("required")) or location_in == "path"
235
+ severity = Severity.BREAKING if required else Severity.WARNING
236
+ result.add(
237
+ Change(
238
+ severity=severity,
239
+ change_type=ChangeType.REMOVED,
240
+ location=loc,
241
+ message=f"Parameter '{name}' ({location_in}) has been removed.",
242
+ category="parameter",
243
+ )
244
+ )
245
+
246
+ for key, param in new_params.items():
247
+ name, location_in = key
248
+ if key not in old_params:
249
+ required = bool(param.get("required")) or location_in == "path"
250
+ severity = Severity.BREAKING if required else Severity.INFO
251
+ message = (
252
+ f"New required parameter added: '{name}' ({location_in})."
253
+ if required
254
+ else f"New optional parameter added: '{name}' ({location_in})."
255
+ )
256
+ result.add(
257
+ Change(
258
+ severity=severity,
259
+ change_type=ChangeType.ADDED,
260
+ location=loc,
261
+ message=message,
262
+ category="parameter",
263
+ )
264
+ )
265
+
266
+ for key in set(old_params) & set(new_params):
267
+ name, location_in = key
268
+ old_param = old_params[key]
269
+ new_param = new_params[key]
270
+ param_loc = f"{loc} > parameter '{name}'"
271
+
272
+ old_required = bool(old_param.get("required")) or location_in == "path"
273
+ new_required = bool(new_param.get("required")) or location_in == "path"
274
+
275
+ if not old_required and new_required:
276
+ result.add(
277
+ Change(
278
+ severity=Severity.BREAKING,
279
+ change_type=ChangeType.MODIFIED,
280
+ location=param_loc,
281
+ message=f"Parameter '{name}' is now required.",
282
+ category="parameter",
283
+ )
284
+ )
285
+ elif old_required and not new_required:
286
+ result.add(
287
+ Change(
288
+ severity=Severity.INFO,
289
+ change_type=ChangeType.MODIFIED,
290
+ location=param_loc,
291
+ message=f"Parameter '{name}' is now optional.",
292
+ category="parameter",
293
+ )
294
+ )
295
+
296
+ old_schema = old_param.get("schema", {})
297
+ new_schema = new_param.get("schema", {})
298
+ if old_schema or new_schema:
299
+ _diff_schema(
300
+ resolve_schema(old_schema, old_spec),
301
+ resolve_schema(new_schema, new_spec),
302
+ param_loc,
303
+ old_spec,
304
+ new_spec,
305
+ result,
306
+ context="request",
307
+ )
308
+
309
+
310
+ def _get_json_schema(content: dict, root_spec: dict) -> Optional[dict]:
311
+ if not content:
312
+ return None
313
+ media = content.get("application/json") or next(iter(content.values()), None)
314
+ if not isinstance(media, dict):
315
+ return None
316
+ schema = media.get("schema")
317
+ if not schema:
318
+ return None
319
+ return resolve_schema(schema, root_spec)
320
+
321
+
322
+ def _diff_request_body(
323
+ loc: str, old_op: dict, new_op: dict, old_spec: dict, new_spec: dict, result: DiffResult
324
+ ) -> None:
325
+ old_body = old_op.get("requestBody")
326
+ new_body = new_op.get("requestBody")
327
+
328
+ if old_body and not new_body:
329
+ result.add(
330
+ Change(
331
+ severity=Severity.BREAKING,
332
+ change_type=ChangeType.REMOVED,
333
+ location=loc,
334
+ message="Request body has been completely removed.",
335
+ category="request_body",
336
+ )
337
+ )
338
+ return
339
+
340
+ if not old_body and new_body:
341
+ required = bool(new_body.get("required"))
342
+ result.add(
343
+ Change(
344
+ severity=Severity.BREAKING if required else Severity.INFO,
345
+ change_type=ChangeType.ADDED,
346
+ location=loc,
347
+ message="A new request body has been added"
348
+ + (" and made required." if required else " (optional)."),
349
+ category="request_body",
350
+ )
351
+ )
352
+ return
353
+
354
+ if not old_body or not new_body:
355
+ return
356
+
357
+ old_required = bool(old_body.get("required"))
358
+ new_required = bool(new_body.get("required"))
359
+ if not old_required and new_required:
360
+ result.add(
361
+ Change(
362
+ severity=Severity.BREAKING,
363
+ change_type=ChangeType.MODIFIED,
364
+ location=loc,
365
+ message="Request body is now required.",
366
+ category="request_body",
367
+ )
368
+ )
369
+
370
+ old_schema = _get_json_schema(old_body.get("content", {}), old_spec)
371
+ new_schema = _get_json_schema(new_body.get("content", {}), new_spec)
372
+
373
+ if old_schema is not None or new_schema is not None:
374
+ _diff_schema(
375
+ old_schema or {},
376
+ new_schema or {},
377
+ f"{loc} > request body",
378
+ old_spec,
379
+ new_spec,
380
+ result,
381
+ context="request",
382
+ )
383
+
384
+
385
+ def _diff_responses(
386
+ loc: str, old_op: dict, new_op: dict, old_spec: dict, new_spec: dict, result: DiffResult
387
+ ) -> None:
388
+ old_responses = old_op.get("responses", {}) or {}
389
+ new_responses = new_op.get("responses", {}) or {}
390
+
391
+ for status in old_responses:
392
+ if status not in new_responses:
393
+ is_success = status.startswith("2")
394
+ result.add(
395
+ Change(
396
+ severity=Severity.BREAKING if is_success else Severity.WARNING,
397
+ change_type=ChangeType.REMOVED,
398
+ location=loc,
399
+ message=f"Response '{status}' has been removed from the definition.",
400
+ category="response",
401
+ )
402
+ )
403
+
404
+ for status in new_responses:
405
+ if status not in old_responses:
406
+ result.add(
407
+ Change(
408
+ severity=Severity.INFO,
409
+ change_type=ChangeType.ADDED,
410
+ location=loc,
411
+ message=f"New response '{status}' has been added.",
412
+ category="response",
413
+ )
414
+ )
415
+
416
+ for status in set(old_responses) & set(new_responses):
417
+ resp_loc = f"{loc} > response {status}"
418
+ old_schema = _get_json_schema(old_responses[status].get("content", {}), old_spec)
419
+ new_schema = _get_json_schema(new_responses[status].get("content", {}), new_spec)
420
+
421
+ if old_schema is not None or new_schema is not None:
422
+ _diff_schema(
423
+ old_schema or {},
424
+ new_schema or {},
425
+ resp_loc,
426
+ old_spec,
427
+ new_spec,
428
+ result,
429
+ context="response",
430
+ )
431
+
432
+
433
+ def _merge_all_of(schema: dict, root_spec: dict, _depth: int = 0) -> dict:
434
+ """Merges the sub-schemas inside allOf into a single schema (simple merge)."""
435
+ if _depth > 10 or "allOf" not in schema:
436
+ return schema
437
+
438
+ merged_properties: dict = dict(schema.get("properties", {}))
439
+ merged_required: list = list(schema.get("required", []))
440
+ merged_type = schema.get("type")
441
+
442
+ for sub in schema.get("allOf", []):
443
+ resolved_sub = resolve_schema(sub, root_spec)
444
+ resolved_sub = _merge_all_of(resolved_sub, root_spec, _depth + 1)
445
+ merged_properties.update(resolved_sub.get("properties", {}))
446
+ merged_required.extend(resolved_sub.get("required", []))
447
+ merged_type = merged_type or resolved_sub.get("type")
448
+
449
+ result = dict(schema)
450
+ result.pop("allOf", None)
451
+ result["properties"] = merged_properties
452
+ result["required"] = list(dict.fromkeys(merged_required))
453
+ if merged_type:
454
+ result["type"] = merged_type
455
+ return result
456
+
457
+
458
+ def _diff_schema(
459
+ old_schema: dict,
460
+ new_schema: dict,
461
+ loc: str,
462
+ old_spec: dict,
463
+ new_spec: dict,
464
+ result: DiffResult,
465
+ context: str,
466
+ _depth: int = 0,
467
+ ) -> None:
468
+ if _depth > 12:
469
+ return
470
+
471
+ old_schema = resolve_schema(old_schema or {}, old_spec)
472
+ new_schema = resolve_schema(new_schema or {}, new_spec)
473
+ old_schema = _merge_all_of(old_schema, old_spec)
474
+ new_schema = _merge_all_of(new_schema, new_spec)
475
+
476
+ if not old_schema and not new_schema:
477
+ return
478
+
479
+ _diff_schema_type(old_schema, new_schema, loc, result)
480
+ _diff_schema_enum(old_schema, new_schema, loc, result)
481
+ _diff_schema_nullable(old_schema, new_schema, loc, context, result)
482
+
483
+ old_type = old_schema.get("type")
484
+ new_type = new_schema.get("type")
485
+
486
+ if old_type == "array" or new_type == "array":
487
+ old_items = resolve_schema(old_schema.get("items", {}), old_spec)
488
+ new_items = resolve_schema(new_schema.get("items", {}), new_spec)
489
+ if old_items or new_items:
490
+ _diff_schema(
491
+ old_items, new_items, f"{loc}[]", old_spec, new_spec, result, context, _depth + 1
492
+ )
493
+ return
494
+
495
+ old_props = old_schema.get("properties", {}) or {}
496
+ new_props = new_schema.get("properties", {}) or {}
497
+ old_required = set(old_schema.get("required", []) or [])
498
+ new_required = set(new_schema.get("required", []) or [])
499
+
500
+ for name in old_props:
501
+ if name not in new_props:
502
+ field_loc = f"{loc} > field '{name}'"
503
+ if context == "response":
504
+ result.add(
505
+ Change(
506
+ severity=Severity.BREAKING,
507
+ change_type=ChangeType.REMOVED,
508
+ location=field_loc,
509
+ message=f"Field '{name}' has been removed from the response, clients relying on it are affected.",
510
+ category="schema",
511
+ )
512
+ )
513
+ else:
514
+ result.add(
515
+ Change(
516
+ severity=Severity.WARNING,
517
+ change_type=ChangeType.REMOVED,
518
+ location=field_loc,
519
+ message=f"Field '{name}' is no longer accepted (removed from the request schema).",
520
+ category="schema",
521
+ )
522
+ )
523
+
524
+ for name in new_props:
525
+ if name not in old_props:
526
+ field_loc = f"{loc} > field '{name}'"
527
+ is_new_required = name in new_required
528
+ if context == "request" and is_new_required:
529
+ result.add(
530
+ Change(
531
+ severity=Severity.BREAKING,
532
+ change_type=ChangeType.ADDED,
533
+ location=field_loc,
534
+ message=f"New required field added: '{name}'. Existing clients must now send it.",
535
+ category="schema",
536
+ )
537
+ )
538
+ else:
539
+ result.add(
540
+ Change(
541
+ severity=Severity.INFO,
542
+ change_type=ChangeType.ADDED,
543
+ location=field_loc,
544
+ message=f"New field added: '{name}'.",
545
+ category="schema",
546
+ )
547
+ )
548
+
549
+ newly_required = (new_required - old_required) & set(old_props) & set(new_props)
550
+ for name in newly_required:
551
+ field_loc = f"{loc} > field '{name}'"
552
+ if context == "request":
553
+ result.add(
554
+ Change(
555
+ severity=Severity.BREAKING,
556
+ change_type=ChangeType.MODIFIED,
557
+ location=field_loc,
558
+ message=f"Field '{name}' is now required.",
559
+ category="schema",
560
+ )
561
+ )
562
+ else:
563
+ result.add(
564
+ Change(
565
+ severity=Severity.INFO,
566
+ change_type=ChangeType.MODIFIED,
567
+ location=field_loc,
568
+ message=f"Field '{name}' is now always guaranteed to be present.",
569
+ category="schema",
570
+ )
571
+ )
572
+
573
+ no_longer_required = (old_required - new_required) & set(old_props) & set(new_props)
574
+ for name in no_longer_required:
575
+ field_loc = f"{loc} > field '{name}'"
576
+ if context == "response":
577
+ result.add(
578
+ Change(
579
+ severity=Severity.BREAKING,
580
+ change_type=ChangeType.MODIFIED,
581
+ location=field_loc,
582
+ message=f"Field '{name}' is no longer guaranteed to be present (became optional).",
583
+ category="schema",
584
+ )
585
+ )
586
+ else:
587
+ result.add(
588
+ Change(
589
+ severity=Severity.INFO,
590
+ change_type=ChangeType.MODIFIED,
591
+ location=field_loc,
592
+ message=f"Field '{name}' is now optional.",
593
+ category="schema",
594
+ )
595
+ )
596
+
597
+ for name in set(old_props) & set(new_props):
598
+ _diff_schema(
599
+ old_props[name],
600
+ new_props[name],
601
+ f"{loc} > field '{name}'",
602
+ old_spec,
603
+ new_spec,
604
+ result,
605
+ context,
606
+ _depth + 1,
607
+ )
608
+
609
+
610
+ def _diff_schema_type(old_schema: dict, new_schema: dict, loc: str, result: DiffResult) -> None:
611
+ old_type = old_schema.get("type")
612
+ new_type = new_schema.get("type")
613
+ if old_type and new_type and old_type != new_type:
614
+ result.add(
615
+ Change(
616
+ severity=Severity.BREAKING,
617
+ change_type=ChangeType.MODIFIED,
618
+ location=loc,
619
+ message=f"Data type changed: '{old_type}' -> '{new_type}'.",
620
+ old_value=old_type,
621
+ new_value=new_type,
622
+ category="schema",
623
+ )
624
+ )
625
+ return
626
+
627
+ old_format = old_schema.get("format")
628
+ new_format = new_schema.get("format")
629
+ if old_format and new_format and old_format != new_format:
630
+ result.add(
631
+ Change(
632
+ severity=Severity.WARNING,
633
+ change_type=ChangeType.MODIFIED,
634
+ location=loc,
635
+ message=f"Format changed: '{old_format}' -> '{new_format}'.",
636
+ old_value=old_format,
637
+ new_value=new_format,
638
+ category="schema",
639
+ )
640
+ )
641
+
642
+
643
+ def _diff_schema_enum(old_schema: dict, new_schema: dict, loc: str, result: DiffResult) -> None:
644
+ old_enum = old_schema.get("enum")
645
+ new_enum = new_schema.get("enum")
646
+ if old_enum is None and new_enum is None:
647
+ return
648
+
649
+ old_set = set(old_enum or [])
650
+ new_set = set(new_enum or [])
651
+
652
+ removed_values = old_set - new_set
653
+ added_values = new_set - old_set
654
+
655
+ if old_enum is not None and new_enum is None:
656
+ result.add(
657
+ Change(
658
+ severity=Severity.INFO,
659
+ change_type=ChangeType.MODIFIED,
660
+ location=loc,
661
+ message="Enum constraint has been completely removed, any value is now accepted.",
662
+ category="schema",
663
+ )
664
+ )
665
+ return
666
+
667
+ if removed_values:
668
+ result.add(
669
+ Change(
670
+ severity=Severity.BREAKING,
671
+ change_type=ChangeType.MODIFIED,
672
+ location=loc,
673
+ message=f"Enum values removed: {sorted(str(v) for v in removed_values)}.",
674
+ old_value=list(removed_values),
675
+ category="schema",
676
+ )
677
+ )
678
+ if added_values:
679
+ result.add(
680
+ Change(
681
+ severity=Severity.INFO,
682
+ change_type=ChangeType.MODIFIED,
683
+ location=loc,
684
+ message=f"New enum values added: {sorted(str(v) for v in added_values)}.",
685
+ new_value=list(added_values),
686
+ category="schema",
687
+ )
688
+ )
689
+
690
+
691
+ def _diff_schema_nullable(
692
+ old_schema: dict, new_schema: dict, loc: str, context: str, result: DiffResult
693
+ ) -> None:
694
+ old_nullable = bool(old_schema.get("nullable", False))
695
+ new_nullable = bool(new_schema.get("nullable", False))
696
+
697
+ if old_nullable == new_nullable:
698
+ return
699
+
700
+ if old_nullable and not new_nullable:
701
+ severity = Severity.BREAKING if context == "response" else Severity.INFO
702
+ result.add(
703
+ Change(
704
+ severity=severity,
705
+ change_type=ChangeType.MODIFIED,
706
+ location=loc,
707
+ message="Field no longer accepts null.",
708
+ category="schema",
709
+ )
710
+ )
711
+ else:
712
+ severity = Severity.INFO if context == "response" else Severity.WARNING
713
+ result.add(
714
+ Change(
715
+ severity=severity,
716
+ change_type=ChangeType.MODIFIED,
717
+ location=loc,
718
+ message="Field now accepts a null value.",
719
+ category="schema",
720
+ )
721
+ )