owtaylor commited on
Commit
5d9b56c
·
1 Parent(s): 5d876b5

openapi: Improve pruning of unused defs

Browse files

Defs that are used only by other unused defs were counted as
used. Fix this by tracing what defs use other defs recursively.

src/fastmcp/utilities/json_schema.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import copy
 
4
 
5
 
6
  def _prune_param(schema: dict, param: str) -> dict:
@@ -24,25 +25,77 @@ def _prune_param(schema: dict, param: str) -> dict:
24
  return schema
25
 
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  def _walk_and_prune(
28
  schema: dict,
29
- prune_defs: bool = False,
30
  prune_titles: bool = False,
31
  prune_additional_properties: bool = False,
32
  ) -> dict:
33
- """Walk the schema and optionally prune titles, unused definitions, and additionalProperties: false."""
34
-
35
- # Will only be used if prune_defs is True
36
- used_defs: set[str] = set()
37
 
38
  def walk(node: object) -> None:
39
  if isinstance(node, dict):
40
- # Process $ref for definition tracking
41
- if prune_defs:
42
- ref = node.get("$ref")
43
- if isinstance(ref, str) and ref.startswith("#/$defs/"):
44
- used_defs.add(ref.split("/")[-1])
45
-
46
  # Remove title if requested
47
  if prune_titles and "title" in node:
48
  node.pop("title")
@@ -62,18 +115,8 @@ def _walk_and_prune(
62
  for v in node:
63
  walk(v)
64
 
65
- # Traverse the schema once
66
  walk(schema)
67
 
68
- # Remove orphaned definitions if requested
69
- if prune_defs:
70
- defs = schema.get("$defs", {})
71
- for def_name in list(defs):
72
- if def_name not in used_defs:
73
- defs.pop(def_name)
74
- if not defs:
75
- schema.pop("$defs", None)
76
-
77
  return schema
78
 
79
 
@@ -109,12 +152,13 @@ def compress_schema(
109
  schema = _prune_param(schema, param=param)
110
 
111
  # Do a single walk to handle pruning operations
112
- if prune_defs or prune_titles or prune_additional_properties:
113
  schema = _walk_and_prune(
114
  schema,
115
- prune_defs=prune_defs,
116
  prune_titles=prune_titles,
117
  prune_additional_properties=prune_additional_properties,
118
  )
 
 
119
 
120
  return schema
 
1
  from __future__ import annotations
2
 
3
  import copy
4
+ from collections import defaultdict
5
 
6
 
7
  def _prune_param(schema: dict, param: str) -> dict:
 
25
  return schema
26
 
27
 
28
+ def _prune_unused_defs(schema: dict) -> dict:
29
+ """Walk the schema and prune unused defs."""
30
+
31
+ root_defs: set[str] = set()
32
+ referenced_by: defaultdict[str, list] = defaultdict(list)
33
+
34
+ defs = schema.get("$defs")
35
+ if defs is None:
36
+ return schema
37
+
38
+ def walk(
39
+ node: object, current_def: str | None = None, skip_defs: bool = False
40
+ ) -> None:
41
+ if isinstance(node, dict):
42
+ # Process $ref for definition tracking
43
+ ref = node.get("$ref")
44
+ if isinstance(ref, str) and ref.startswith("#/$defs/"):
45
+ def_name = ref.split("/")[-1]
46
+ if current_def:
47
+ referenced_by[def_name].append(current_def)
48
+ else:
49
+ root_defs.add(def_name)
50
+
51
+ # Walk children
52
+ for k, v in node.items():
53
+ if skip_defs and k == "$defs":
54
+ continue
55
+
56
+ walk(v, current_def=current_def)
57
+
58
+ elif isinstance(node, list):
59
+ for v in node:
60
+ walk(v)
61
+
62
+ # Traverse the schema once, skipping the $defs
63
+ walk(schema, skip_defs=True)
64
+
65
+ # Now figure out what defs reference other defs
66
+ for def_name, value in defs.items():
67
+ walk(value, current_def=def_name)
68
+
69
+ # Figure out what defs were referenced directly or recursively
70
+ def def_is_referenced(def_name):
71
+ if def_name in root_defs:
72
+ return True
73
+ references = referenced_by.get(def_name)
74
+ if references:
75
+ for reference in references:
76
+ if def_is_referenced(reference):
77
+ return True
78
+ return False
79
+
80
+ # Remove orphaned definitions if requested
81
+ for def_name in list(defs):
82
+ if not def_is_referenced(def_name):
83
+ defs.pop(def_name)
84
+ if not defs:
85
+ schema.pop("$defs", None)
86
+
87
+ return schema
88
+
89
+
90
  def _walk_and_prune(
91
  schema: dict,
 
92
  prune_titles: bool = False,
93
  prune_additional_properties: bool = False,
94
  ) -> dict:
95
+ """Walk the schema and optionally prune titles and additionalProperties: false."""
 
 
 
96
 
97
  def walk(node: object) -> None:
98
  if isinstance(node, dict):
 
 
 
 
 
 
99
  # Remove title if requested
100
  if prune_titles and "title" in node:
101
  node.pop("title")
 
115
  for v in node:
116
  walk(v)
117
 
 
118
  walk(schema)
119
 
 
 
 
 
 
 
 
 
 
120
  return schema
121
 
122
 
 
152
  schema = _prune_param(schema, param=param)
153
 
154
  # Do a single walk to handle pruning operations
155
+ if prune_titles or prune_additional_properties:
156
  schema = _walk_and_prune(
157
  schema,
 
158
  prune_titles=prune_titles,
159
  prune_additional_properties=prune_additional_properties,
160
  )
161
+ if prune_defs:
162
+ schema = _prune_unused_defs(schema)
163
 
164
  return schema
tests/utilities/test_json_schema.py CHANGED
@@ -1,14 +1,11 @@
1
  from fastmcp.utilities.json_schema import (
2
  _prune_param,
 
3
  _walk_and_prune,
4
  compress_schema,
5
  )
6
 
7
-
8
- # Create wrappers for backward compatibility with tests
9
- def _prune_unused_defs(schema):
10
- """Wrapper for _walk_and_prune that only prunes definitions."""
11
- return _walk_and_prune(schema, prune_defs=True)
12
 
13
 
14
  def _prune_additional_properties(schema):
@@ -95,6 +92,21 @@ class TestPruneUnusedDefs:
95
  assert "nested_def" in result["$defs"]
96
  assert "unused_def" not in result["$defs"]
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  def test_array_references_kept(self):
99
  """Test that definitions referenced in array items are kept."""
100
  schema = {
 
1
  from fastmcp.utilities.json_schema import (
2
  _prune_param,
3
+ _prune_unused_defs,
4
  _walk_and_prune,
5
  compress_schema,
6
  )
7
 
8
+ # Wrapper for backward compatibility with tests
 
 
 
 
9
 
10
 
11
  def _prune_additional_properties(schema):
 
92
  assert "nested_def" in result["$defs"]
93
  assert "unused_def" not in result["$defs"]
94
 
95
+ def test_nested_references_removed(self):
96
+ """Test that definitions referenced via nesting in unused defs are removed."""
97
+ schema = {
98
+ "properties": {},
99
+ "$defs": {
100
+ "foo_def": {
101
+ "type": "object",
102
+ "properties": {"nested": {"$ref": "#/$defs/nested_def"}},
103
+ },
104
+ "nested_def": {"type": "string"},
105
+ },
106
+ }
107
+ result = _prune_unused_defs(schema)
108
+ assert "$defs" not in result
109
+
110
  def test_array_references_kept(self):
111
  """Test that definitions referenced in array items are kept."""
112
  schema = {