Jeremiah Lowin commited on
Commit
46d8d36
·
unverified ·
2 Parent(s): 73cce7536d32e2

Merge pull request #449 from jlowin/prune_titles

Browse files
src/fastmcp/utilities/json_schema.py CHANGED
@@ -1,7 +1,6 @@
1
  from __future__ import annotations
2
 
3
  import copy
4
- from collections.abc import Mapping, Sequence
5
 
6
 
7
  def _prune_param(schema: dict, param: str) -> dict:
@@ -25,32 +24,55 @@ def _prune_param(schema: dict, param: str) -> dict:
25
  return schema
26
 
27
 
28
- def _prune_unused_defs(schema: dict) -> dict:
29
- """Remove unused definitions from the schema."""
30
- # collect all remaining local $ref targets
 
 
 
 
 
 
31
  used_defs: set[str] = set()
32
 
33
- def walk(node: object) -> None: # depth-first traversal
34
- if isinstance(node, Mapping):
35
- ref = node.get("$ref")
36
- if isinstance(ref, str) and ref.startswith("#/$defs/"):
37
- used_defs.add(ref.split("/")[-1])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  for v in node.values():
39
  walk(v)
40
- elif isinstance(node, Sequence) and not isinstance(node, str | bytes):
 
41
  for v in node:
42
  walk(v)
43
 
 
44
  walk(schema)
45
 
46
- # remove orphaned definitions
47
-
48
- defs = schema.get("$defs", {})
49
- for def_name in list(defs):
50
- if def_name not in used_defs:
51
- defs.pop(def_name)
52
- if not defs:
53
- schema.pop("$defs", None)
54
 
55
  return schema
56
 
@@ -67,16 +89,32 @@ def compress_schema(
67
  prune_params: list[str] | None = None,
68
  prune_defs: bool = True,
69
  prune_additional_properties: bool = True,
 
70
  ) -> dict:
71
  """
72
  Remove the given parameters from the schema.
73
 
 
 
 
 
 
 
74
  """
 
75
  schema = copy.deepcopy(schema)
 
 
76
  for param in prune_params or []:
77
  schema = _prune_param(schema, param=param)
78
- if prune_defs:
79
- schema = _prune_unused_defs(schema)
80
- if prune_additional_properties:
81
- schema = _prune_additional_properties(schema)
 
 
 
 
 
 
82
  return schema
 
1
  from __future__ import annotations
2
 
3
  import copy
 
4
 
5
 
6
  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")
49
+
50
+ # Remove additionalProperties: false at any level if requested
51
+ if (
52
+ prune_additional_properties
53
+ and node.get("additionalProperties", None) is False
54
+ ):
55
+ node.pop("additionalProperties")
56
+
57
+ # Walk children
58
  for v in node.values():
59
  walk(v)
60
+
61
+ elif isinstance(node, list):
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
 
 
89
  prune_params: list[str] | None = None,
90
  prune_defs: bool = True,
91
  prune_additional_properties: bool = True,
92
+ prune_titles: bool = False,
93
  ) -> dict:
94
  """
95
  Remove the given parameters from the schema.
96
 
97
+ Args:
98
+ schema: The schema to compress
99
+ prune_params: List of parameter names to remove from properties
100
+ prune_defs: Whether to remove unused definitions
101
+ prune_additional_properties: Whether to remove additionalProperties: false
102
+ prune_titles: Whether to remove title fields from the schema
103
  """
104
+ # Make a copy so we don't modify the original
105
  schema = copy.deepcopy(schema)
106
+
107
+ # Remove specific parameters if requested
108
  for param in prune_params or []:
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
tests/utilities/test_json_schema.py CHANGED
@@ -1,11 +1,21 @@
1
  from fastmcp.utilities.json_schema import (
2
- _prune_additional_properties,
3
  _prune_param,
4
- _prune_unused_defs,
5
  compress_schema,
6
  )
7
 
8
 
 
 
 
 
 
 
 
 
 
 
 
9
  class TestPruneParam:
10
  """Tests for the _prune_param function."""
11
 
@@ -244,3 +254,51 @@ class TestCompressSchema:
244
  assert "$defs" not in result # Both defs should be gone
245
  # Check that additionalProperties was removed
246
  assert "additionalProperties" not in result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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):
15
+ """Wrapper for _walk_and_prune that only prunes additionalProperties: false."""
16
+ return _walk_and_prune(schema, prune_additional_properties=True)
17
+
18
+
19
  class TestPruneParam:
20
  """Tests for the _prune_param function."""
21
 
 
254
  assert "$defs" not in result # Both defs should be gone
255
  # Check that additionalProperties was removed
256
  assert "additionalProperties" not in result
257
+
258
+ def test_prune_titles(self):
259
+ """Test pruning title fields."""
260
+ schema = {
261
+ "title": "Root Schema",
262
+ "type": "object",
263
+ "properties": {
264
+ "foo": {"title": "Foo Property", "type": "string"},
265
+ "bar": {
266
+ "title": "Bar Property",
267
+ "type": "object",
268
+ "properties": {
269
+ "nested": {"title": "Nested Property", "type": "string"}
270
+ },
271
+ },
272
+ },
273
+ }
274
+ result = compress_schema(schema, prune_titles=True)
275
+ assert "title" not in result
276
+ assert "title" not in result["properties"]["foo"]
277
+ assert "title" not in result["properties"]["bar"]
278
+ assert "title" not in result["properties"]["bar"]["properties"]["nested"]
279
+
280
+ def test_prune_nested_additional_properties(self):
281
+ """Test pruning additionalProperties: false at all levels."""
282
+ schema = {
283
+ "type": "object",
284
+ "additionalProperties": False,
285
+ "properties": {
286
+ "foo": {
287
+ "type": "object",
288
+ "additionalProperties": False,
289
+ "properties": {
290
+ "nested": {
291
+ "type": "object",
292
+ "additionalProperties": False,
293
+ }
294
+ },
295
+ },
296
+ },
297
+ }
298
+ result = compress_schema(schema)
299
+ assert "additionalProperties" not in result
300
+ assert "additionalProperties" not in result["properties"]["foo"]
301
+ assert (
302
+ "additionalProperties"
303
+ not in result["properties"]["foo"]["properties"]["nested"]
304
+ )