chuk-tool-processor 0.1.6__py3-none-any.whl → 0.2__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.

Potentially problematic release.


This version of chuk-tool-processor might be problematic. Click here for more details.

Files changed (46) hide show
  1. chuk_tool_processor/core/processor.py +345 -132
  2. chuk_tool_processor/execution/strategies/inprocess_strategy.py +522 -71
  3. chuk_tool_processor/execution/strategies/subprocess_strategy.py +559 -64
  4. chuk_tool_processor/execution/tool_executor.py +282 -24
  5. chuk_tool_processor/execution/wrappers/caching.py +465 -123
  6. chuk_tool_processor/execution/wrappers/rate_limiting.py +199 -86
  7. chuk_tool_processor/execution/wrappers/retry.py +133 -23
  8. chuk_tool_processor/logging/__init__.py +83 -10
  9. chuk_tool_processor/logging/context.py +218 -22
  10. chuk_tool_processor/logging/formatter.py +56 -13
  11. chuk_tool_processor/logging/helpers.py +91 -16
  12. chuk_tool_processor/logging/metrics.py +75 -6
  13. chuk_tool_processor/mcp/mcp_tool.py +80 -35
  14. chuk_tool_processor/mcp/register_mcp_tools.py +74 -56
  15. chuk_tool_processor/mcp/setup_mcp_sse.py +41 -36
  16. chuk_tool_processor/mcp/setup_mcp_stdio.py +39 -37
  17. chuk_tool_processor/mcp/transport/sse_transport.py +351 -105
  18. chuk_tool_processor/models/execution_strategy.py +52 -3
  19. chuk_tool_processor/models/streaming_tool.py +110 -0
  20. chuk_tool_processor/models/tool_call.py +56 -4
  21. chuk_tool_processor/models/tool_result.py +115 -9
  22. chuk_tool_processor/models/validated_tool.py +15 -13
  23. chuk_tool_processor/plugins/discovery.py +115 -70
  24. chuk_tool_processor/plugins/parsers/base.py +13 -5
  25. chuk_tool_processor/plugins/parsers/{function_call_tool_plugin.py → function_call_tool.py} +39 -20
  26. chuk_tool_processor/plugins/parsers/json_tool.py +50 -0
  27. chuk_tool_processor/plugins/parsers/openai_tool.py +88 -0
  28. chuk_tool_processor/plugins/parsers/xml_tool.py +74 -20
  29. chuk_tool_processor/registry/__init__.py +46 -7
  30. chuk_tool_processor/registry/auto_register.py +92 -28
  31. chuk_tool_processor/registry/decorators.py +134 -11
  32. chuk_tool_processor/registry/interface.py +48 -14
  33. chuk_tool_processor/registry/metadata.py +52 -6
  34. chuk_tool_processor/registry/provider.py +75 -36
  35. chuk_tool_processor/registry/providers/__init__.py +49 -10
  36. chuk_tool_processor/registry/providers/memory.py +59 -48
  37. chuk_tool_processor/registry/tool_export.py +208 -39
  38. chuk_tool_processor/utils/validation.py +18 -13
  39. chuk_tool_processor-0.2.dist-info/METADATA +401 -0
  40. chuk_tool_processor-0.2.dist-info/RECORD +58 -0
  41. {chuk_tool_processor-0.1.6.dist-info → chuk_tool_processor-0.2.dist-info}/WHEEL +1 -1
  42. chuk_tool_processor/plugins/parsers/json_tool_plugin.py +0 -38
  43. chuk_tool_processor/plugins/parsers/openai_tool_plugin.py +0 -76
  44. chuk_tool_processor-0.1.6.dist-info/METADATA +0 -462
  45. chuk_tool_processor-0.1.6.dist-info/RECORD +0 -57
  46. {chuk_tool_processor-0.1.6.dist-info → chuk_tool_processor-0.2.dist-info}/top_level.txt +0 -0
@@ -1,11 +1,10 @@
1
1
  # chuk_tool_processor/registry/providers/memory.py
2
- # chuk_tool_processor/registry/providers/memory.py
3
2
  """
4
- In-memory implementation of the tool registry.
3
+ In-memory implementation of the asynchronous tool registry.
5
4
  """
6
-
7
5
  from __future__ import annotations
8
6
 
7
+ import asyncio
9
8
  import inspect
10
9
  from typing import Any, Dict, List, Optional, Tuple
11
10
 
@@ -16,9 +15,10 @@ from chuk_tool_processor.registry.metadata import ToolMetadata
16
15
 
17
16
  class InMemoryToolRegistry(ToolRegistryInterface):
18
17
  """
19
- In-memory implementation of ToolRegistryInterface with namespace support.
18
+ In-memory implementation of the async ToolRegistryInterface with namespace support.
20
19
 
21
20
  Suitable for single-process apps or tests; not persisted across processes.
21
+ Thread-safe with asyncio locking.
22
22
  """
23
23
 
24
24
  # ------------------------------------------------------------------ #
@@ -30,72 +30,80 @@ class InMemoryToolRegistry(ToolRegistryInterface):
30
30
  self._tools: Dict[str, Dict[str, Any]] = {}
31
31
  # {namespace: {tool_name: ToolMetadata}}
32
32
  self._metadata: Dict[str, Dict[str, ToolMetadata]] = {}
33
+ # Lock for thread safety
34
+ self._lock = asyncio.Lock()
33
35
 
34
36
  # ------------------------------------------------------------------ #
35
37
  # registration
36
38
  # ------------------------------------------------------------------ #
37
39
 
38
- def register_tool(
40
+ async def register_tool(
39
41
  self,
40
42
  tool: Any,
41
43
  name: Optional[str] = None,
42
44
  namespace: str = "default",
43
45
  metadata: Optional[Dict[str, Any]] = None,
44
46
  ) -> None:
45
- # ensure namespace buckets
46
- self._tools.setdefault(namespace, {})
47
- self._metadata.setdefault(namespace, {})
48
-
49
- key = name or getattr(tool, "__name__", None) or repr(tool)
50
- self._tools[namespace][key] = tool
51
-
52
- # build metadata -------------------------------------------------
53
- is_async = inspect.iscoroutinefunction(getattr(tool, "execute", None))
54
-
55
- # default description -> docstring
56
- description = (
57
- (inspect.getdoc(tool) or "").strip()
58
- if not (metadata and "description" in metadata)
59
- else None
60
- )
61
-
62
- meta_dict: Dict[str, Any] = {
63
- "name": key,
64
- "namespace": namespace,
65
- "is_async": is_async,
66
- }
67
- if description:
68
- meta_dict["description"] = description
69
- if metadata:
70
- meta_dict.update(metadata)
71
-
72
- self._metadata[namespace][key] = ToolMetadata(**meta_dict)
47
+ """Register a tool in the registry asynchronously."""
48
+ async with self._lock:
49
+ # ensure namespace buckets
50
+ self._tools.setdefault(namespace, {})
51
+ self._metadata.setdefault(namespace, {})
52
+
53
+ key = name or getattr(tool, "__name__", None) or repr(tool)
54
+ self._tools[namespace][key] = tool
55
+
56
+ # build metadata -------------------------------------------------
57
+ is_async = inspect.iscoroutinefunction(getattr(tool, "execute", None))
58
+
59
+ # default description -> docstring
60
+ description = (
61
+ (inspect.getdoc(tool) or "").strip()
62
+ if not (metadata and "description" in metadata)
63
+ else None
64
+ )
65
+
66
+ meta_dict: Dict[str, Any] = {
67
+ "name": key,
68
+ "namespace": namespace,
69
+ "is_async": is_async,
70
+ }
71
+ if description:
72
+ meta_dict["description"] = description
73
+ if metadata:
74
+ meta_dict.update(metadata)
75
+
76
+ self._metadata[namespace][key] = ToolMetadata(**meta_dict)
73
77
 
74
78
  # ------------------------------------------------------------------ #
75
79
  # retrieval
76
80
  # ------------------------------------------------------------------ #
77
81
 
78
- def get_tool(self, name: str, namespace: str = "default") -> Optional[Any]:
82
+ async def get_tool(self, name: str, namespace: str = "default") -> Optional[Any]:
83
+ """Retrieve a tool by name and namespace asynchronously."""
84
+ # Read operations don't need locking for better concurrency
79
85
  return self._tools.get(namespace, {}).get(name)
80
86
 
81
- def get_tool_strict(self, name: str, namespace: str = "default") -> Any:
82
- tool = self.get_tool(name, namespace)
87
+ async def get_tool_strict(self, name: str, namespace: str = "default") -> Any:
88
+ """Get a tool with strict validation, raising if not found."""
89
+ tool = await self.get_tool(name, namespace)
83
90
  if tool is None:
84
91
  raise ToolNotFoundError(f"{namespace}.{name}")
85
92
  return tool
86
93
 
87
- def get_metadata(
94
+ async def get_metadata(
88
95
  self, name: str, namespace: str = "default"
89
96
  ) -> Optional[ToolMetadata]:
97
+ """Get metadata for a tool asynchronously."""
90
98
  return self._metadata.get(namespace, {}).get(name)
91
99
 
92
100
  # ------------------------------------------------------------------ #
93
101
  # listing helpers
94
102
  # ------------------------------------------------------------------ #
95
103
 
96
- def list_tools(self, namespace: Optional[str] = None) -> List[Tuple[str, str]]:
104
+ async def list_tools(self, namespace: Optional[str] = None) -> List[Tuple[str, str]]:
97
105
  """
98
- Return a list of ``(namespace, name)`` tuples.
106
+ Return a list of ``(namespace, name)`` tuples asynchronously.
99
107
  """
100
108
  if namespace:
101
109
  return [
@@ -107,18 +115,21 @@ class InMemoryToolRegistry(ToolRegistryInterface):
107
115
  result.extend((ns, n) for n in tools.keys())
108
116
  return result
109
117
 
110
- def list_namespaces(self) -> List[str]:
118
+ async def list_namespaces(self) -> List[str]:
119
+ """List all namespaces asynchronously."""
111
120
  return list(self._tools.keys())
112
121
 
113
- def list_metadata(self, namespace: str | None = None) -> List[ToolMetadata]:
122
+ async def list_metadata(self, namespace: Optional[str] = None) -> List[ToolMetadata]:
114
123
  """
115
- Return *all* :class:`ToolMetadata` objects.
124
+ Return all ToolMetadata objects asynchronously.
116
125
 
117
- Parameters
118
- ----------
119
- namespace
120
- ``None`` *(default)* metadata from **all** namespaces
121
- • ``"some_ns"`` – only that namespace
126
+ Args:
127
+ namespace: Optional filter by namespace.
128
+ • None (default) – metadata from all namespaces
129
+ "some_ns"only that namespace
130
+
131
+ Returns:
132
+ List of ToolMetadata objects.
122
133
  """
123
134
  if namespace is not None:
124
135
  return list(self._metadata.get(namespace, {}).values())
@@ -127,4 +138,4 @@ class InMemoryToolRegistry(ToolRegistryInterface):
127
138
  result: List[ToolMetadata] = []
128
139
  for ns_meta in self._metadata.values():
129
140
  result.extend(ns_meta.values())
130
- return result
141
+ return result
@@ -1,46 +1,77 @@
1
1
  # chuk_tool_processor/registry/tool_export.py
2
2
  """
3
- Helpers that expose all registered tools in various formats and
3
+ Async helpers that expose all registered tools in various formats and
4
4
  translate an OpenAI `function.name` back to the matching tool.
5
5
  """
6
6
  from __future__ import annotations
7
7
 
8
- from typing import Dict, List
8
+ import asyncio
9
+ from typing import Dict, List, Any, Optional, Mapping
9
10
 
11
+ # registry
10
12
  from .provider import ToolRegistryProvider
11
13
 
12
14
  # --------------------------------------------------------------------------- #
13
- # internal cache so tool-name lookup is O(1)
15
+ # internal cache so tool-name lookup is O(1) with async protection
14
16
  # --------------------------------------------------------------------------- #
15
- _OPENAI_NAME_CACHE: dict[str, object] | None = None
17
+ _OPENAI_NAME_CACHE: Optional[Dict[str, Any]] = None
18
+ _CACHE_LOCK = asyncio.Lock()
16
19
 
17
20
 
18
- def _build_openai_name_cache() -> None:
19
- """Populate the global reverse-lookup table once."""
21
+ async def _build_openai_name_cache() -> None:
22
+ """
23
+ Populate the global reverse-lookup table once asynchronously.
24
+
25
+ This function is thread-safe and will only build the cache once,
26
+ even with concurrent calls.
27
+ """
20
28
  global _OPENAI_NAME_CACHE
21
- if _OPENAI_NAME_CACHE is not None: # already built
29
+
30
+ # Fast path - cache already exists
31
+ if _OPENAI_NAME_CACHE is not None:
22
32
  return
23
33
 
24
- _OPENAI_NAME_CACHE = {}
25
- reg = ToolRegistryProvider.get_registry()
26
-
27
- for ns, key in reg.list_tools():
28
- tool = reg.get_tool(key, ns)
29
-
30
- # registry key -> tool
31
- _OPENAI_NAME_CACHE[key] = tool
32
-
33
- # class name -> tool (legacy)
34
- _OPENAI_NAME_CACHE[tool.__class__.__name__] = tool
35
-
36
- # OpenAI name -> tool (may differ from both above)
37
- _OPENAI_NAME_CACHE[tool.to_openai()["function"]["name"]] = tool
34
+ # Slow path - build the cache with proper locking
35
+ async with _CACHE_LOCK:
36
+ # Double-check pattern: check again after acquiring the lock
37
+ if _OPENAI_NAME_CACHE is not None:
38
+ return
39
+
40
+ # Initialize an empty cache
41
+ _OPENAI_NAME_CACHE = {}
42
+
43
+ # Get the registry
44
+ reg = await ToolRegistryProvider.get_registry()
45
+
46
+ # Get all tools and their names
47
+ tools_list = await reg.list_tools()
48
+
49
+ for ns, key in tools_list:
50
+ # Get the tool
51
+ tool = await reg.get_tool(key, ns)
52
+ if tool is None:
53
+ continue
54
+
55
+ # ▸ registry key -> tool
56
+ _OPENAI_NAME_CACHE[key] = tool
57
+
58
+ # ▸ class name -> tool (legacy)
59
+ _OPENAI_NAME_CACHE[tool.__class__.__name__] = tool
60
+
61
+ # ▸ OpenAI name -> tool (may differ from both above)
62
+ try:
63
+ openai_spec = tool.to_openai()
64
+ openai_name = openai_spec["function"]["name"]
65
+ _OPENAI_NAME_CACHE[openai_name] = tool
66
+ except (AttributeError, KeyError, TypeError):
67
+ # Skip tools that don't have proper OpenAI specs
68
+ pass
38
69
 
39
70
 
40
71
  # --------------------------------------------------------------------------- #
41
72
  # public helpers
42
73
  # --------------------------------------------------------------------------- #
43
- def openai_functions() -> List[Dict]:
74
+ async def openai_functions() -> List[Dict]:
44
75
  """
45
76
  Return **all** registered tools in the exact schema the Chat-Completions
46
77
  API expects in its ``tools=[ … ]`` parameter.
@@ -48,29 +79,167 @@ def openai_functions() -> List[Dict]:
48
79
  The ``function.name`` is always the *registry key* so that the round-trip
49
80
  (export → model → parser) stays consistent even when the class name and
50
81
  the registered key differ.
82
+
83
+ Returns:
84
+ List of OpenAI function specifications
51
85
  """
52
- reg = ToolRegistryProvider.get_registry()
53
- specs: list[dict] = []
54
-
55
- for ns, key in reg.list_tools():
56
- tool = reg.get_tool(key, ns)
57
- spec = tool.to_openai()
58
- spec["function"]["name"] = key # ensure round-trip consistency
59
- specs.append(spec)
60
-
61
- # Ensure the cache is built the first time we export
62
- _build_openai_name_cache()
86
+ # Get the registry
87
+ reg = await ToolRegistryProvider.get_registry()
88
+ specs: List[Dict[str, Any]] = []
89
+
90
+ # List all tools
91
+ tools_list = await reg.list_tools()
92
+
93
+ for ns, key in tools_list:
94
+ # Get each tool
95
+ tool = await reg.get_tool(key, ns)
96
+ if tool is None:
97
+ continue
98
+
99
+ try:
100
+ # Get the OpenAI spec
101
+ spec = tool.to_openai()
102
+ # Override the name to ensure round-trip consistency
103
+ spec["function"]["name"] = key
104
+ specs.append(spec)
105
+ except (AttributeError, TypeError):
106
+ # Skip tools that don't support OpenAI format
107
+ pass
108
+
109
+ # Ensure the cache is built
110
+ await _build_openai_name_cache()
63
111
  return specs
64
112
 
65
113
 
66
- def tool_by_openai_name(name: str):
114
+ async def tool_by_openai_name(name: str) -> Any:
67
115
  """
68
- Map an OpenAI ``function.name`` back to the registered tool.
69
-
70
- Raises ``KeyError`` if the name is unknown.
116
+ Map an OpenAI ``function.name`` back to the registered tool asynchronously.
117
+
118
+ Args:
119
+ name: The OpenAI function name
120
+
121
+ Returns:
122
+ The tool implementation
123
+
124
+ Raises:
125
+ KeyError: If the name is unknown
71
126
  """
72
- _build_openai_name_cache()
127
+ # Ensure the cache is built
128
+ await _build_openai_name_cache()
129
+
130
+ # Look up the tool
73
131
  try:
74
- return _OPENAI_NAME_CACHE[name] # type: ignore[index]
132
+ if _OPENAI_NAME_CACHE is None:
133
+ raise KeyError(f"Tool cache not initialized")
134
+
135
+ return _OPENAI_NAME_CACHE[name]
75
136
  except (KeyError, TypeError):
76
137
  raise KeyError(f"No tool registered for OpenAI name {name!r}") from None
138
+
139
+
140
+ async def clear_name_cache() -> None:
141
+ """
142
+ Clear the OpenAI name cache.
143
+
144
+ This is useful in tests or when the registry changes significantly.
145
+ """
146
+ global _OPENAI_NAME_CACHE
147
+ async with _CACHE_LOCK:
148
+ _OPENAI_NAME_CACHE = None
149
+
150
+
151
+ async def export_tools_as_openapi(
152
+ title: str = "Tool API",
153
+ version: str = "1.0.0",
154
+ description: str = "API for registered tools",
155
+ ) -> Dict[str, Any]:
156
+ """
157
+ Export all registered tools as an OpenAPI specification.
158
+
159
+ Args:
160
+ title: API title
161
+ version: API version
162
+ description: API description
163
+
164
+ Returns:
165
+ OpenAPI specification as a dictionary
166
+ """
167
+ # Get the registry
168
+ reg = await ToolRegistryProvider.get_registry()
169
+
170
+ # Build paths and components
171
+ paths: Dict[str, Any] = {}
172
+ schemas: Dict[str, Any] = {}
173
+
174
+ # List all tools
175
+ tools_list = await reg.list_tools()
176
+
177
+ for ns, key in tools_list:
178
+ # Get tool and metadata
179
+ tool = await reg.get_tool(key, ns)
180
+ metadata = await reg.get_metadata(key, ns)
181
+
182
+ if tool is None or metadata is None:
183
+ continue
184
+
185
+ # Create path
186
+ path = f"/{ns}/{key}"
187
+
188
+ # Get schemas from tool if available
189
+ arg_schema = None
190
+ result_schema = None
191
+
192
+ if hasattr(tool, "Arguments") and hasattr(tool.Arguments, "model_json_schema"):
193
+ arg_schema = tool.Arguments.model_json_schema()
194
+ schemas[f"{key}Args"] = arg_schema
195
+
196
+ if hasattr(tool, "Result") and hasattr(tool.Result, "model_json_schema"):
197
+ result_schema = tool.Result.model_json_schema()
198
+ schemas[f"{key}Result"] = result_schema
199
+
200
+ # Add path
201
+ paths[path] = {
202
+ "post": {
203
+ "summary": metadata.description or f"Execute {key}",
204
+ "operationId": f"execute_{ns}_{key}",
205
+ "tags": [ns],
206
+ "requestBody": {
207
+ "required": True,
208
+ "content": {
209
+ "application/json": {
210
+ "schema": {"$ref": f"#/components/schemas/{key}Args"} if arg_schema else {}
211
+ }
212
+ }
213
+ },
214
+ "responses": {
215
+ "200": {
216
+ "description": "Successful operation",
217
+ "content": {
218
+ "application/json": {
219
+ "schema": {"$ref": f"#/components/schemas/{key}Result"} if result_schema else {}
220
+ }
221
+ }
222
+ },
223
+ "400": {
224
+ "description": "Bad request"
225
+ },
226
+ "500": {
227
+ "description": "Internal server error"
228
+ }
229
+ }
230
+ }
231
+ }
232
+
233
+ # Build the OpenAPI spec
234
+ return {
235
+ "openapi": "3.0.0",
236
+ "info": {
237
+ "title": title,
238
+ "version": version,
239
+ "description": description
240
+ },
241
+ "paths": paths,
242
+ "components": {
243
+ "schemas": schemas
244
+ }
245
+ }
@@ -1,6 +1,6 @@
1
1
  # chuk_tool_processor/utils/validation.py
2
2
  """
3
- Runtime helpers for validating tool inputs / outputs with Pydantic.
3
+ Async runtime helpers for validating tool inputs / outputs with Pydantic.
4
4
 
5
5
  Public API
6
6
  ----------
@@ -10,11 +10,12 @@ validate_result(tool_name, fn, result) -> Any
10
10
  """
11
11
  from __future__ import annotations
12
12
  import inspect
13
+ import asyncio
13
14
  from functools import lru_cache, wraps
14
- from typing import Any, Callable, Dict, get_type_hints
15
+ from typing import Any, Callable, Dict, get_type_hints, Awaitable
15
16
  from pydantic import BaseModel, ValidationError, create_model, Extra
16
17
 
17
- # excpetion
18
+ # exception
18
19
  from chuk_tool_processor.core.exceptions import ToolValidationError
19
20
 
20
21
  __all__ = [
@@ -66,19 +67,21 @@ def _result_model(tool_name: str, fn: Callable) -> type[BaseModel] | None:
66
67
 
67
68
 
68
69
  # --------------------------------------------------------------------------- #
69
- # public validation helpers
70
+ # public validation helpers - synced with async patterns
70
71
  # --------------------------------------------------------------------------- #
71
72
 
72
73
 
73
74
  def validate_arguments(tool_name: str, fn: Callable, args: Dict[str, Any]) -> Dict[str, Any]:
75
+ """Validate function arguments against type hints."""
74
76
  try:
75
77
  model = _arg_model(tool_name, fn)
76
- return model(**args).dict()
78
+ return model(**args).model_dump()
77
79
  except ValidationError as exc:
78
80
  raise ToolValidationError(tool_name, exc.errors()) from exc
79
81
 
80
82
 
81
83
  def validate_result(tool_name: str, fn: Callable, result: Any) -> Any:
84
+ """Validate function return value against return type hint."""
82
85
  model = _result_model(tool_name, fn)
83
86
  if model is None: # no annotation ⇒ no validation
84
87
  return result
@@ -89,33 +92,35 @@ def validate_result(tool_name: str, fn: Callable, result: Any) -> Any:
89
92
 
90
93
 
91
94
  # --------------------------------------------------------------------------- #
92
- # decorator for classic imperative tools
95
+ # decorator for classic "imperative" tools - now requires async
93
96
  # --------------------------------------------------------------------------- #
94
97
 
95
98
 
96
99
  def with_validation(cls):
97
100
  """
98
- Wrap *execute* / *_execute* so that their arguments & return values
99
- are type-checked each call.
101
+ Wrap an async *execute* method with argument & result validation.
100
102
 
101
103
  ```
102
104
  @with_validation
103
105
  class MyTool:
104
- def execute(self, x: int, y: int) -> int:
106
+ async def execute(self, x: int, y: int) -> int:
105
107
  return x + y
106
108
  ```
107
109
  """
108
-
109
110
  # Which method did the user provide?
110
111
  fn_name = "_execute" if hasattr(cls, "_execute") else "execute"
111
112
  original = getattr(cls, fn_name)
113
+
114
+ # Ensure the method is async
115
+ if not inspect.iscoroutinefunction(original):
116
+ raise TypeError(f"Tool {cls.__name__} must have an async {fn_name} method")
112
117
 
113
118
  @wraps(original)
114
- def _validated(self, **kwargs):
119
+ async def _validated(self, **kwargs):
115
120
  name = cls.__name__
116
121
  kwargs = validate_arguments(name, original, kwargs)
117
- res = original(self, **kwargs)
122
+ res = await original(self, **kwargs)
118
123
  return validate_result(name, original, res)
119
124
 
120
125
  setattr(cls, fn_name, _validated)
121
- return cls
126
+ return cls