yaicli 0.6.3__py3-none-any.whl → 0.7.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.
- pyproject.toml +14 -1
- yaicli/cli.py +2 -2
- yaicli/config.py +1 -1
- yaicli/const.py +19 -8
- yaicli/entry.py +24 -1
- yaicli/functions/__init__.py +13 -1
- yaicli/llms/client.py +71 -38
- yaicli/llms/provider.py +3 -0
- yaicli/llms/providers/ai21_provider.py +33 -1
- yaicli/llms/providers/chatglm_provider.py +38 -11
- yaicli/llms/providers/cohere_provider.py +6 -3
- yaicli/llms/providers/deepseek_provider.py +2 -1
- yaicli/llms/providers/doubao_provider.py +0 -14
- yaicli/llms/providers/gemini_provider.py +29 -28
- yaicli/llms/providers/huggingface_provider.py +40 -0
- yaicli/llms/providers/infiniai_provider.py +4 -2
- yaicli/llms/providers/modelscope_provider.py +2 -1
- yaicli/llms/providers/openai_provider.py +20 -11
- yaicli/llms/providers/siliconflow_provider.py +2 -1
- yaicli/tools/__init__.py +127 -0
- yaicli/tools/function.py +90 -0
- yaicli/tools/mcp.py +459 -0
- yaicli/utils.py +34 -0
- {yaicli-0.6.3.dist-info → yaicli-0.7.0.dist-info}/METADATA +231 -19
- yaicli-0.7.0.dist-info/RECORD +49 -0
- yaicli/tools.py +0 -159
- yaicli-0.6.3.dist-info/RECORD +0 -46
- {yaicli-0.6.3.dist-info → yaicli-0.7.0.dist-info}/WHEEL +0 -0
- {yaicli-0.6.3.dist-info → yaicli-0.7.0.dist-info}/entry_points.txt +0 -0
- {yaicli-0.6.3.dist-info → yaicli-0.7.0.dist-info}/licenses/LICENSE +0 -0
yaicli/tools/mcp.py
ADDED
@@ -0,0 +1,459 @@
|
|
1
|
+
import inspect
|
2
|
+
import json
|
3
|
+
import threading
|
4
|
+
from dataclasses import dataclass
|
5
|
+
from pathlib import Path
|
6
|
+
from typing import Any, Callable, Dict, List, Optional
|
7
|
+
|
8
|
+
from fastmcp.client import Client
|
9
|
+
from fastmcp.utilities.types import MCPContent
|
10
|
+
from mcp.types import TextContent, Tool
|
11
|
+
|
12
|
+
from ..const import MCP_JSON_PATH
|
13
|
+
from ..utils import get_or_create_event_loop
|
14
|
+
|
15
|
+
MCP_TOOL_NAME_PREFIX = "_mcp__"
|
16
|
+
|
17
|
+
|
18
|
+
def gen_mcp_tool_name(name: str) -> str:
|
19
|
+
"""Generate MCP tool name
|
20
|
+
Add the prefix _mcp__ to the tool name.
|
21
|
+
|
22
|
+
<original_tool_name> ==> _mcp__<original_tool_name>
|
23
|
+
|
24
|
+
Args:
|
25
|
+
name: Original tool name
|
26
|
+
Returns:
|
27
|
+
str
|
28
|
+
"""
|
29
|
+
if not name.startswith(MCP_TOOL_NAME_PREFIX):
|
30
|
+
name = f"{MCP_TOOL_NAME_PREFIX}{name}"
|
31
|
+
return name
|
32
|
+
|
33
|
+
|
34
|
+
def parse_mcp_tool_name(name: str) -> str:
|
35
|
+
"""Parse MCP tool name
|
36
|
+
Remove the prefix _mcp__ from the tool name.
|
37
|
+
|
38
|
+
_mcp__<original_tool_name> ==> <original_tool_name>
|
39
|
+
|
40
|
+
Args:
|
41
|
+
name: MCP tool name
|
42
|
+
Returns:
|
43
|
+
str
|
44
|
+
"""
|
45
|
+
return name.removeprefix(MCP_TOOL_NAME_PREFIX)
|
46
|
+
|
47
|
+
|
48
|
+
@dataclass
|
49
|
+
class MCPConfig:
|
50
|
+
"""MCP config class"""
|
51
|
+
|
52
|
+
servers: Dict[str, Any]
|
53
|
+
|
54
|
+
@classmethod
|
55
|
+
def from_file(cls, config_path: Path) -> "MCPConfig":
|
56
|
+
"""Load config from file
|
57
|
+
|
58
|
+
Args:
|
59
|
+
config_path: Path to MCP config file
|
60
|
+
Returns:
|
61
|
+
MCPConfig
|
62
|
+
Raises:
|
63
|
+
FileNotFoundError: If the MCP config file is not found
|
64
|
+
"""
|
65
|
+
if not config_path.exists():
|
66
|
+
raise FileNotFoundError(f"MCP config file not found: {config_path}")
|
67
|
+
|
68
|
+
config_data = json.loads(config_path.read_text(encoding="utf-8"))
|
69
|
+
|
70
|
+
# Convert config format (type -> transport)
|
71
|
+
for server_config in config_data.get("mcpServers", {}).values():
|
72
|
+
if "type" in server_config:
|
73
|
+
server_config["transport"] = server_config.pop("type")
|
74
|
+
|
75
|
+
return cls(servers=config_data)
|
76
|
+
|
77
|
+
|
78
|
+
class MCP:
|
79
|
+
"""MCP tool wrapper"""
|
80
|
+
|
81
|
+
def __init__(self, name: str, description: str, parameters: Dict[str, Any]):
|
82
|
+
self.name = gen_mcp_tool_name(name)
|
83
|
+
self.description = description
|
84
|
+
self.parameters = parameters
|
85
|
+
|
86
|
+
def execute(self, **kwargs) -> str:
|
87
|
+
"""Execute tool
|
88
|
+
This function will execute the tool and return the result.
|
89
|
+
It will return the formatted result.
|
90
|
+
|
91
|
+
Args:
|
92
|
+
**kwargs: Tool parameters
|
93
|
+
Returns:
|
94
|
+
str
|
95
|
+
"""
|
96
|
+
try:
|
97
|
+
client = get_mcp_manager().client
|
98
|
+
result = client.call_tool(self.name, **kwargs)
|
99
|
+
return self._format_result(result)
|
100
|
+
except Exception as e:
|
101
|
+
return f"Tool '{self.name}' execution failed: {e}"
|
102
|
+
|
103
|
+
def _format_result(self, result: List[MCPContent]) -> str:
|
104
|
+
"""Format result to string
|
105
|
+
This function is used to format the result to string.
|
106
|
+
It will return the text of the first result if the result is a TextContent.
|
107
|
+
It will return the string representation of the first result if the result is not a TextContent.
|
108
|
+
|
109
|
+
Args:
|
110
|
+
result: List[MCPContent]
|
111
|
+
Returns:
|
112
|
+
str
|
113
|
+
"""
|
114
|
+
if not result:
|
115
|
+
return ""
|
116
|
+
|
117
|
+
first_result = result[0]
|
118
|
+
if isinstance(first_result, TextContent):
|
119
|
+
return first_result.text
|
120
|
+
return str(first_result)
|
121
|
+
|
122
|
+
def __repr__(self) -> str:
|
123
|
+
return f"MCP(name='{self.name}', description='{self.description}', parameters={self.parameters})"
|
124
|
+
|
125
|
+
|
126
|
+
class MCPClient:
|
127
|
+
"""MCP client (thread-safe singleton)"""
|
128
|
+
|
129
|
+
_instance: Optional["MCPClient"] = None
|
130
|
+
_lock = threading.Lock()
|
131
|
+
|
132
|
+
def __new__(cls, *args, **kwargs) -> "MCPClient":
|
133
|
+
"""Thread-safe singleton implementation"""
|
134
|
+
if cls._instance is None:
|
135
|
+
with cls._lock:
|
136
|
+
if cls._instance is None:
|
137
|
+
cls._instance = super().__new__(cls)
|
138
|
+
cls._instance._initialized = False
|
139
|
+
return cls._instance
|
140
|
+
|
141
|
+
def __init__(self, config: Optional[MCPConfig] = None):
|
142
|
+
"""Initialize MCP client
|
143
|
+
|
144
|
+
Convert async functions to sync functions.
|
145
|
+
This is a workaround to make the MCP client thread-safe.
|
146
|
+
"""
|
147
|
+
if getattr(self, "_initialized", False):
|
148
|
+
return
|
149
|
+
if not config:
|
150
|
+
config = MCPConfig.from_file(MCP_JSON_PATH)
|
151
|
+
|
152
|
+
self.config = config
|
153
|
+
self._client = Client(self.config.servers)
|
154
|
+
|
155
|
+
# _tools_map: "_mcp__<original_tool_name>" -> MCP
|
156
|
+
self._tools_map: Optional[Dict[str, MCP]] = None
|
157
|
+
self._tools: Optional[List[Tool]] = None
|
158
|
+
self._initialized = True
|
159
|
+
|
160
|
+
def ping(self) -> None:
|
161
|
+
"""Test connection"""
|
162
|
+
loop = get_or_create_event_loop()
|
163
|
+
loop.run_until_complete(self._ping_async())
|
164
|
+
|
165
|
+
async def _ping_async(self) -> None:
|
166
|
+
"""Async ping implementation"""
|
167
|
+
async with self._client:
|
168
|
+
await self._client.ping()
|
169
|
+
|
170
|
+
def list_tools(self) -> List[Tool]:
|
171
|
+
"""Get tool list
|
172
|
+
This function will list all tools from the MCP server.
|
173
|
+
Returns:
|
174
|
+
List[Tool]: Tool object list from fastmcp.types.Tool
|
175
|
+
"""
|
176
|
+
if self._tools is None:
|
177
|
+
loop = get_or_create_event_loop()
|
178
|
+
self._tools = loop.run_until_complete(self._list_tools_async())
|
179
|
+
return self._tools
|
180
|
+
|
181
|
+
async def _list_tools_async(self) -> List[Tool]:
|
182
|
+
"""Async get tool list"""
|
183
|
+
async with self._client:
|
184
|
+
return await self._client.list_tools()
|
185
|
+
|
186
|
+
def call_tool(self, tool_name: str, **kwargs) -> List[MCPContent]:
|
187
|
+
"""Call tool"""
|
188
|
+
tool_name = parse_mcp_tool_name(tool_name)
|
189
|
+
loop = get_or_create_event_loop()
|
190
|
+
return loop.run_until_complete(self._call_tool_async(tool_name, **kwargs))
|
191
|
+
|
192
|
+
async def _call_tool_async(self, tool_name: str, **kwargs) -> List[MCPContent]:
|
193
|
+
"""Async call tool"""
|
194
|
+
async with self._client:
|
195
|
+
return await self._client.call_tool(tool_name, kwargs)
|
196
|
+
|
197
|
+
@property
|
198
|
+
def tools(self) -> List[Tool]:
|
199
|
+
"""Get tool list
|
200
|
+
This property will be lazy loaded.
|
201
|
+
Returns:
|
202
|
+
List[Tool]: Tool object list from fastmcp.types.Tool
|
203
|
+
"""
|
204
|
+
if self._tools is None:
|
205
|
+
self._tools = self.list_tools()
|
206
|
+
return self._tools
|
207
|
+
|
208
|
+
@property
|
209
|
+
def tools_map(self) -> Dict[str, MCP]:
|
210
|
+
"""Get MCP tool object mapping
|
211
|
+
key: _mcp__<original_tool_name>
|
212
|
+
value: MCP tool object
|
213
|
+
This property will be lazy loaded.
|
214
|
+
Returns:
|
215
|
+
Dict[str, MCP]: MCP tool object mapping
|
216
|
+
"""
|
217
|
+
if self._tools_map is None:
|
218
|
+
self._tools_map = {}
|
219
|
+
for tool in self.tools:
|
220
|
+
self._tools_map[gen_mcp_tool_name(tool.name)] = MCP(tool.name, tool.description or "", tool.inputSchema)
|
221
|
+
return self._tools_map
|
222
|
+
|
223
|
+
def get_tool(self, name: str) -> MCP:
|
224
|
+
"""Get MCP tool object
|
225
|
+
|
226
|
+
This function will ensure the tool name is prefixed with _mcp__<original_tool_name>
|
227
|
+
and raise an error if the tool name is not found.
|
228
|
+
|
229
|
+
Args:
|
230
|
+
name: _mcp__<original_tool_name>
|
231
|
+
Returns:
|
232
|
+
MCP tool object
|
233
|
+
Raises:
|
234
|
+
ValueError: If the tool name is not found
|
235
|
+
"""
|
236
|
+
name = gen_mcp_tool_name(name)
|
237
|
+
if name not in self.tools_map:
|
238
|
+
available_tools = list(self.tools_map.keys())
|
239
|
+
raise ValueError(f"MCP tool '{name}' not found. Available tools: {available_tools}")
|
240
|
+
return self.tools_map[name]
|
241
|
+
|
242
|
+
def __del__(self):
|
243
|
+
"""Close client"""
|
244
|
+
loop = get_or_create_event_loop()
|
245
|
+
loop.run_until_complete(self._client.close())
|
246
|
+
|
247
|
+
|
248
|
+
class MCPToolConverter:
|
249
|
+
"""Tool format converter"""
|
250
|
+
|
251
|
+
def __init__(self, client: MCPClient):
|
252
|
+
self.client = client
|
253
|
+
|
254
|
+
def to_openai_format(self) -> List[Dict[str, Any]]:
|
255
|
+
"""Convert to OpenAI function call format"""
|
256
|
+
openai_tools = []
|
257
|
+
|
258
|
+
for tool in self.client.tools:
|
259
|
+
openai_tool = {
|
260
|
+
"type": "function",
|
261
|
+
"function": {
|
262
|
+
"name": gen_mcp_tool_name(tool.name),
|
263
|
+
"description": tool.description or "",
|
264
|
+
"parameters": tool.inputSchema,
|
265
|
+
},
|
266
|
+
}
|
267
|
+
openai_tools.append(openai_tool)
|
268
|
+
|
269
|
+
return openai_tools
|
270
|
+
|
271
|
+
def _create_parameter_from_schema(
|
272
|
+
self, name: str, prop_info: Dict[str, Any], required: List[str]
|
273
|
+
) -> inspect.Parameter:
|
274
|
+
"""Create inspect.Parameter from JSON schema property
|
275
|
+
|
276
|
+
This function is used to create inspect.Parameter from JSON schema property.
|
277
|
+
'array' ==> List[T]
|
278
|
+
'enum' ==> Literal[T]
|
279
|
+
'string' ==> str
|
280
|
+
'integer' ==> int
|
281
|
+
'number' ==> float | int (if default is int, it will be converted to int)
|
282
|
+
'boolean' ==> bool
|
283
|
+
'object' ==> dict
|
284
|
+
|
285
|
+
Args:
|
286
|
+
name: Parameter name
|
287
|
+
prop_info: Property info
|
288
|
+
required: Required parameters
|
289
|
+
Returns:
|
290
|
+
inspect.Parameter
|
291
|
+
"""
|
292
|
+
# Ensure parameter type
|
293
|
+
param_type = prop_info.get("type", "string")
|
294
|
+
|
295
|
+
# Type mapping
|
296
|
+
type_mapping = {
|
297
|
+
"string": str,
|
298
|
+
"integer": int,
|
299
|
+
"number": float,
|
300
|
+
"boolean": bool,
|
301
|
+
"array": list,
|
302
|
+
"object": dict,
|
303
|
+
}
|
304
|
+
|
305
|
+
# Update annotation based on type and default value
|
306
|
+
annotation = type_mapping.get(param_type, str)
|
307
|
+
if annotation == float:
|
308
|
+
default = prop_info.get("default", None)
|
309
|
+
if default is not None:
|
310
|
+
annotation = int if isinstance(default, int) else float
|
311
|
+
|
312
|
+
# Handle array type
|
313
|
+
if param_type == "array" and "items" in prop_info:
|
314
|
+
item_type = prop_info["items"].get("type", "string")
|
315
|
+
item_annotation = type_mapping.get(item_type, str)
|
316
|
+
annotation = List[item_annotation]
|
317
|
+
|
318
|
+
# Handle enum type
|
319
|
+
if "enum" in prop_info:
|
320
|
+
from typing import Literal
|
321
|
+
|
322
|
+
annotation = Literal[tuple(prop_info["enum"])] # type: ignore
|
323
|
+
|
324
|
+
# Handle optional parameter
|
325
|
+
if name not in required:
|
326
|
+
from typing import Optional
|
327
|
+
|
328
|
+
annotation = Optional[annotation]
|
329
|
+
|
330
|
+
# Ensure default value
|
331
|
+
if name in required:
|
332
|
+
default = inspect.Parameter.empty
|
333
|
+
else:
|
334
|
+
default = prop_info.get("default", None)
|
335
|
+
|
336
|
+
return inspect.Parameter(name, inspect.Parameter.POSITIONAL_OR_KEYWORD, default=default, annotation=annotation)
|
337
|
+
|
338
|
+
def _create_dynamic_function(self, tool_obj: MCP) -> Callable:
|
339
|
+
"""Create dynamic function with proper signature and type annotations
|
340
|
+
|
341
|
+
This function is used to create a dynamic function with proper signature and type annotations.
|
342
|
+
It will create a dynamic function that can be used as a tool in the LLM.
|
343
|
+
Callable.__signature__ = inspect.Signature(parameters=inspect.Parameter(name, inspect.Parameter.POSITIONAL_OR_KEYWORD, default=default, annotation=annotation))
|
344
|
+
Callable.__name__ = _mcp__<original_tool_name>
|
345
|
+
Callable.__doc__ = tool_obj.description
|
346
|
+
Callable.__annotations__ = {param.name: param.annotation for param in params}
|
347
|
+
Callable.__annotations__["return"] = str # MCP tools return string
|
348
|
+
|
349
|
+
Args:
|
350
|
+
tool_obj: MCP tool object
|
351
|
+
Returns:
|
352
|
+
Callable
|
353
|
+
"""
|
354
|
+
properties = tool_obj.parameters.get("properties", {})
|
355
|
+
required = tool_obj.parameters.get("required", [])
|
356
|
+
|
357
|
+
# Create parameter list
|
358
|
+
params = [
|
359
|
+
self._create_parameter_from_schema(name, prop_info, required) for name, prop_info in properties.items()
|
360
|
+
]
|
361
|
+
|
362
|
+
# Dynamic function
|
363
|
+
def dynamic_function(**kwargs):
|
364
|
+
return tool_obj.execute(**kwargs)
|
365
|
+
|
366
|
+
# Set function attributes
|
367
|
+
dynamic_function.__signature__ = inspect.Signature(parameters=params)
|
368
|
+
dynamic_function.__name__ = gen_mcp_tool_name(tool_obj.name)
|
369
|
+
dynamic_function.__doc__ = tool_obj.description
|
370
|
+
|
371
|
+
# Set type annotations (simulate get_type_hints result)
|
372
|
+
annotations = {param.name: param.annotation for param in params}
|
373
|
+
annotations["return"] = str # MCP tools return string
|
374
|
+
dynamic_function.__annotations__ = annotations
|
375
|
+
|
376
|
+
return dynamic_function
|
377
|
+
|
378
|
+
def to_gemini_format(self) -> List[Callable]:
|
379
|
+
"""Convert to Gemini function call format
|
380
|
+
Gemini automatic function calling parses the function signature and type annotations to generate the function declaration.
|
381
|
+
So we need to create a dynamic function with proper signature and type annotations.
|
382
|
+
"""
|
383
|
+
return [self._create_dynamic_function(tool) for tool in self.client.tools_map.values()]
|
384
|
+
|
385
|
+
|
386
|
+
class MCPManager:
|
387
|
+
"""MCP manager - provide unified API interface"""
|
388
|
+
|
389
|
+
def __init__(self, config_path: Optional[Path] = None):
|
390
|
+
self.config_path = config_path or MCP_JSON_PATH
|
391
|
+
self._client: Optional[MCPClient] = None
|
392
|
+
self._converter: Optional[MCPToolConverter] = None
|
393
|
+
|
394
|
+
@property
|
395
|
+
def client(self) -> MCPClient:
|
396
|
+
"""Lazy load client"""
|
397
|
+
if self._client is None:
|
398
|
+
config = MCPConfig.from_file(self.config_path)
|
399
|
+
self._client = MCPClient(config)
|
400
|
+
return self._client
|
401
|
+
|
402
|
+
@property
|
403
|
+
def converter(self) -> MCPToolConverter:
|
404
|
+
"""Lazy load converter"""
|
405
|
+
if self._converter is None:
|
406
|
+
self._converter = MCPToolConverter(self.client)
|
407
|
+
return self._converter
|
408
|
+
|
409
|
+
def ping(self) -> None:
|
410
|
+
"""Test connection"""
|
411
|
+
self.client.ping()
|
412
|
+
|
413
|
+
def list_tools(self) -> List[Tool]:
|
414
|
+
"""Get tool name list"""
|
415
|
+
return self.client.tools
|
416
|
+
|
417
|
+
def get_tool(self, name: str) -> MCP:
|
418
|
+
"""Get tool"""
|
419
|
+
# Verify tool exists
|
420
|
+
name = gen_mcp_tool_name(name)
|
421
|
+
return self.client.get_tool(name)
|
422
|
+
|
423
|
+
def execute_tool(self, name: str, **kwargs) -> str:
|
424
|
+
"""Execute tool"""
|
425
|
+
tool = self.get_tool(name)
|
426
|
+
return tool.execute(**kwargs)
|
427
|
+
|
428
|
+
def to_openai_tools(self) -> List[Dict[str, Any]]:
|
429
|
+
"""Convert to OpenAI tool format"""
|
430
|
+
return self.converter.to_openai_format()
|
431
|
+
|
432
|
+
def to_gemini_tools(self) -> List[Callable]:
|
433
|
+
"""Convert to Gemini tool format"""
|
434
|
+
return self.converter.to_gemini_format()
|
435
|
+
|
436
|
+
|
437
|
+
# Global instance
|
438
|
+
_mcp_manager: Optional[MCPManager] = None
|
439
|
+
|
440
|
+
|
441
|
+
def get_mcp_manager(config_path: Optional[Path] = None) -> MCPManager:
|
442
|
+
"""Get MCP manager instance
|
443
|
+
|
444
|
+
Args:
|
445
|
+
config_path: Path to MCP config file
|
446
|
+
Returns:
|
447
|
+
MCPManager
|
448
|
+
Raises:
|
449
|
+
FileNotFoundError: If the MCP config file is not found
|
450
|
+
"""
|
451
|
+
global _mcp_manager
|
452
|
+
if _mcp_manager is None:
|
453
|
+
_mcp_manager = MCPManager(config_path)
|
454
|
+
return _mcp_manager
|
455
|
+
|
456
|
+
|
457
|
+
def get_mcp(name: str) -> MCP:
|
458
|
+
"""Get MCP tool - compatible with original API"""
|
459
|
+
return get_mcp_manager().get_tool(name)
|
yaicli/utils.py
CHANGED
@@ -1,4 +1,6 @@
|
|
1
|
+
import asyncio
|
1
2
|
import platform
|
3
|
+
from functools import wraps
|
2
4
|
from os import getenv
|
3
5
|
from os.path import basename, pathsep
|
4
6
|
from typing import Any, Callable, Optional, TypeVar
|
@@ -132,3 +134,35 @@ def str2bool(value: Any) -> bool:
|
|
132
134
|
|
133
135
|
# Handle empty strings and other invalid values
|
134
136
|
raise ValueError(f"Invalid boolean value: {value}")
|
137
|
+
|
138
|
+
|
139
|
+
def get_or_create_event_loop() -> asyncio.AbstractEventLoop:
|
140
|
+
"""
|
141
|
+
Get the current event loop or create a new one if it doesn't exist.
|
142
|
+
Compatible with Python 3.10+.
|
143
|
+
|
144
|
+
Returns:
|
145
|
+
asyncio.AbstractEventLoop: The current event loop or a new one if it doesn't exist.
|
146
|
+
"""
|
147
|
+
try:
|
148
|
+
# Try to get the current running event loop
|
149
|
+
return asyncio.get_running_loop()
|
150
|
+
except RuntimeError:
|
151
|
+
# No running event loop, get or create event loop
|
152
|
+
try:
|
153
|
+
return asyncio.get_event_loop()
|
154
|
+
except RuntimeError:
|
155
|
+
# Create a new event loop and set it as the current thread's event loop
|
156
|
+
loop = asyncio.new_event_loop()
|
157
|
+
asyncio.set_event_loop(loop)
|
158
|
+
return loop
|
159
|
+
|
160
|
+
|
161
|
+
def wrap_function(func: Callable) -> Callable:
|
162
|
+
"""Wrap a function to add a name and docstring"""
|
163
|
+
|
164
|
+
@wraps(func)
|
165
|
+
def wrapper(*args, **kwargs):
|
166
|
+
return func(*args, **kwargs)
|
167
|
+
|
168
|
+
return wrapper
|