universal-mcp 0.1.17__py3-none-any.whl → 0.1.18__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.
- universal_mcp/applications/application.py +106 -16
- universal_mcp/cli.py +44 -4
- universal_mcp/config.py +4 -5
- universal_mcp/exceptions.py +4 -0
- universal_mcp/integrations/__init__.py +1 -1
- universal_mcp/integrations/integration.py +9 -9
- universal_mcp/servers/__init__.py +2 -2
- universal_mcp/servers/server.py +125 -54
- universal_mcp/tools/manager.py +24 -10
- universal_mcp/utils/agentr.py +10 -14
- universal_mcp/utils/openapi/api_splitter.py +400 -0
- universal_mcp/utils/openapi/openapi.py +299 -116
- {universal_mcp-0.1.17.dist-info → universal_mcp-0.1.18.dist-info}/METADATA +2 -2
- {universal_mcp-0.1.17.dist-info → universal_mcp-0.1.18.dist-info}/RECORD +17 -16
- {universal_mcp-0.1.17.dist-info → universal_mcp-0.1.18.dist-info}/WHEEL +0 -0
- {universal_mcp-0.1.17.dist-info → universal_mcp-0.1.18.dist-info}/entry_points.txt +0 -0
- {universal_mcp-0.1.17.dist-info → universal_mcp-0.1.18.dist-info}/licenses/LICENSE +0 -0
universal_mcp/tools/manager.py
CHANGED
@@ -1,3 +1,4 @@
|
|
1
|
+
import re
|
1
2
|
from collections.abc import Callable
|
2
3
|
from typing import Any
|
3
4
|
|
@@ -19,14 +20,21 @@ DEFAULT_IMPORTANT_TAG = "important"
|
|
19
20
|
TOOL_NAME_SEPARATOR = "_"
|
20
21
|
|
21
22
|
|
22
|
-
def _filter_by_name(tools: list[Tool], tool_names: list[str]) -> list[Tool]:
|
23
|
+
def _filter_by_name(tools: list[Tool], tool_names: list[str] | None) -> list[Tool]:
|
23
24
|
if not tool_names:
|
24
25
|
return tools
|
25
|
-
|
26
|
+
logger.debug(f"Filtering tools by name: {tool_names}")
|
27
|
+
filtered_tools = []
|
28
|
+
for tool in tools:
|
29
|
+
for name in tool_names:
|
30
|
+
if re.search(name, tool.name):
|
31
|
+
filtered_tools.append(tool)
|
32
|
+
return filtered_tools
|
26
33
|
|
27
34
|
|
28
35
|
def _filter_by_tags(tools: list[Tool], tags: list[str] | None) -> list[Tool]:
|
29
|
-
|
36
|
+
if not tags:
|
37
|
+
return tools
|
30
38
|
return [tool for tool in tools if any(tag in tool.tags for tag in tags)]
|
31
39
|
|
32
40
|
|
@@ -77,7 +85,6 @@ class ToolManager:
|
|
77
85
|
tools = list(self._tools.values())
|
78
86
|
if tags:
|
79
87
|
tools = _filter_by_tags(tools, tags)
|
80
|
-
|
81
88
|
if format == ToolFormat.MCP:
|
82
89
|
tools = [convert_tool_to_mcp_tool(tool) for tool in tools]
|
83
90
|
elif format == ToolFormat.LANGCHAIN:
|
@@ -86,7 +93,6 @@ class ToolManager:
|
|
86
93
|
tools = [convert_tool_to_openai_tool(tool) for tool in tools]
|
87
94
|
else:
|
88
95
|
raise ValueError(f"Invalid format: {format}")
|
89
|
-
|
90
96
|
return tools
|
91
97
|
|
92
98
|
def add_tool(self, fn: Callable[..., Any] | Tool, name: str | None = None) -> Tool:
|
@@ -148,14 +154,14 @@ class ToolManager:
|
|
148
154
|
def register_tools_from_app(
|
149
155
|
self,
|
150
156
|
app: BaseApplication,
|
151
|
-
tool_names: list[str] = None,
|
152
|
-
tags: list[str] = None,
|
157
|
+
tool_names: list[str] | None = None,
|
158
|
+
tags: list[str] | None = None,
|
153
159
|
) -> None:
|
154
160
|
"""Register tools from an application.
|
155
161
|
|
156
162
|
Args:
|
157
163
|
app: The application to register tools from.
|
158
|
-
|
164
|
+
tool_names: Optional list of specific tool names to register.
|
159
165
|
tags: Optional list of tags to filter tools by.
|
160
166
|
"""
|
161
167
|
try:
|
@@ -186,8 +192,16 @@ class ToolManager:
|
|
186
192
|
tool_name = getattr(function, "__name__", "unknown")
|
187
193
|
logger.error(f"Failed to create Tool from '{tool_name}' in {app.name}: {e}")
|
188
194
|
|
189
|
-
|
190
|
-
|
195
|
+
if tags:
|
196
|
+
tools = _filter_by_tags(tools, tags)
|
197
|
+
|
198
|
+
if tool_names:
|
199
|
+
tools = _filter_by_name(tools, tool_names)
|
200
|
+
|
201
|
+
# If no tool names or tags are provided, use the default important tag
|
202
|
+
if not tool_names and not tags:
|
203
|
+
tools = _filter_by_tags(tools, [DEFAULT_IMPORTANT_TAG])
|
204
|
+
|
191
205
|
self.register_tools(tools)
|
192
206
|
return
|
193
207
|
|
universal_mcp/utils/agentr.py
CHANGED
@@ -5,10 +5,9 @@ from loguru import logger
|
|
5
5
|
|
6
6
|
from universal_mcp.config import AppConfig
|
7
7
|
from universal_mcp.exceptions import NotAuthorizedError
|
8
|
-
from universal_mcp.utils.singleton import Singleton
|
9
8
|
|
10
9
|
|
11
|
-
class AgentrClient
|
10
|
+
class AgentrClient:
|
12
11
|
"""Helper class for AgentR API operations.
|
13
12
|
|
14
13
|
This class provides utility methods for interacting with the AgentR API,
|
@@ -27,6 +26,9 @@ class AgentrClient(metaclass=Singleton):
|
|
27
26
|
)
|
28
27
|
raise ValueError("AgentR API key required - get one at https://agentr.dev")
|
29
28
|
self.base_url = (base_url or os.getenv("AGENTR_BASE_URL", "https://api.agentr.dev")).rstrip("/")
|
29
|
+
self.client = httpx.Client(
|
30
|
+
base_url=self.base_url, headers={"X-API-KEY": self.api_key}, timeout=30, follow_redirects=True
|
31
|
+
)
|
30
32
|
|
31
33
|
def get_credentials(self, integration_name: str) -> dict:
|
32
34
|
"""Get credentials for an integration from the AgentR API.
|
@@ -41,9 +43,8 @@ class AgentrClient(metaclass=Singleton):
|
|
41
43
|
NotAuthorizedError: If credentials are not found (404 response)
|
42
44
|
HTTPError: For other API errors
|
43
45
|
"""
|
44
|
-
response =
|
45
|
-
f"
|
46
|
-
headers={"accept": "application/json", "X-API-KEY": self.api_key},
|
46
|
+
response = self.client.get(
|
47
|
+
f"/api/{integration_name}/credentials/",
|
47
48
|
)
|
48
49
|
if response.status_code == 404:
|
49
50
|
logger.warning(f"No credentials found for {integration_name}. Requesting authorization...")
|
@@ -64,15 +65,14 @@ class AgentrClient(metaclass=Singleton):
|
|
64
65
|
Raises:
|
65
66
|
HTTPError: If API request fails
|
66
67
|
"""
|
67
|
-
response =
|
68
|
-
f"
|
69
|
-
headers={"X-API-KEY": self.api_key},
|
68
|
+
response = self.client.get(
|
69
|
+
f"/api/{integration_name}/authorize/",
|
70
70
|
)
|
71
71
|
response.raise_for_status()
|
72
72
|
url = response.json()
|
73
73
|
return f"Please ask the user to visit the following url to authorize the application: {url}. Render the url in proper markdown format with a clickable link."
|
74
74
|
|
75
|
-
def fetch_apps(self) -> list[
|
75
|
+
def fetch_apps(self) -> list[AppConfig]:
|
76
76
|
"""Fetch available apps from AgentR API.
|
77
77
|
|
78
78
|
Returns:
|
@@ -81,11 +81,7 @@ class AgentrClient(metaclass=Singleton):
|
|
81
81
|
Raises:
|
82
82
|
httpx.HTTPError: If API request fails
|
83
83
|
"""
|
84
|
-
response =
|
85
|
-
f"{self.base_url}/api/apps/",
|
86
|
-
headers={"X-API-KEY": self.api_key},
|
87
|
-
timeout=10,
|
88
|
-
)
|
84
|
+
response = self.client.get("/api/apps/")
|
89
85
|
response.raise_for_status()
|
90
86
|
data = response.json()
|
91
87
|
return [AppConfig.model_validate(app) for app in data]
|
@@ -0,0 +1,400 @@
|
|
1
|
+
import ast
|
2
|
+
import re
|
3
|
+
from collections import defaultdict
|
4
|
+
from keyword import iskeyword
|
5
|
+
from pathlib import Path
|
6
|
+
|
7
|
+
API_SEGMENT_BASE_CODE = '''
|
8
|
+
from typing import Any
|
9
|
+
|
10
|
+
class APISegmentBase:
|
11
|
+
def __init__(self, main_app_client: Any):
|
12
|
+
self.main_app_client = main_app_client
|
13
|
+
|
14
|
+
def _get(self, url: str, params: dict = None, **kwargs):
|
15
|
+
return self.main_app_client._get(url, params=params, **kwargs)
|
16
|
+
|
17
|
+
def _post(self, url: str, data: Any = None, files: Any = None, params: dict = None, content_type: str = None, **kwargs):
|
18
|
+
return self.main_app_client._post(url, data=data, files=files, params=params, content_type=content_type, **kwargs)
|
19
|
+
|
20
|
+
def _put(self, url: str, data: Any = None, files: Any = None, params: dict = None, content_type: str = None, **kwargs):
|
21
|
+
return self.main_app_client._put(url, data=data, files=files, params=params, content_type=content_type, **kwargs)
|
22
|
+
|
23
|
+
def _patch(self, url: str, data: Any = None, params: dict = None, **kwargs):
|
24
|
+
return self.main_app_client._patch(url, data=data, params=params, **kwargs)
|
25
|
+
|
26
|
+
def _delete(self, url: str, params: dict = None, **kwargs):
|
27
|
+
return self.main_app_client._delete(url, params=params, **kwargs)
|
28
|
+
'''
|
29
|
+
|
30
|
+
def get_sanitized_path_segment(openapi_path: str) -> str:
|
31
|
+
# Remove leading/trailing slashes and split
|
32
|
+
path_parts = [part for part in openapi_path.strip("/").split("/") if part]
|
33
|
+
|
34
|
+
if not path_parts:
|
35
|
+
return "default_api"
|
36
|
+
|
37
|
+
# Handle common prefixes like /api/ or /v1/api/ etc.
|
38
|
+
known_prefixes = ["api"]
|
39
|
+
while len(path_parts) > 0 and path_parts[0].lower() in known_prefixes:
|
40
|
+
path_parts.pop(0)
|
41
|
+
if not path_parts:
|
42
|
+
return "default_api"
|
43
|
+
|
44
|
+
segment_to_use = "default_api"
|
45
|
+
|
46
|
+
# check if the current first segment is version-like (e.g., "2", "v1", "v0")
|
47
|
+
if path_parts:
|
48
|
+
first_segment = path_parts[0]
|
49
|
+
# Check if it's purely numeric (like "2") or matches "v" followed by digits
|
50
|
+
is_version_segment = first_segment.isdigit() or re.match(r"v\\d+", first_segment.lower())
|
51
|
+
|
52
|
+
if is_version_segment and len(path_parts) > 1:
|
53
|
+
# If it's a version segment and there's something after it, use the next segment
|
54
|
+
segment_to_use = path_parts[1]
|
55
|
+
elif not is_version_segment:
|
56
|
+
# If it's not a version segment, use it directly
|
57
|
+
segment_to_use = path_parts[0]
|
58
|
+
else:
|
59
|
+
|
60
|
+
segment_to_use = f"api_{first_segment}"
|
61
|
+
else: # Path was empty after stripping prefixes (e.g. "/api/")
|
62
|
+
return "default_api" # segment_to_use remains "default_api"
|
63
|
+
|
64
|
+
# Sanitize the chosen segment to be a valid Python identifier component
|
65
|
+
# Replace non-alphanumeric (excluding underscore) with underscore
|
66
|
+
sanitized_segment = re.sub(r"[^a-zA-Z0-9_]", "_", segment_to_use)
|
67
|
+
|
68
|
+
# Remove leading/trailing underscores that might result from sanitization
|
69
|
+
sanitized_segment = sanitized_segment.strip("_")
|
70
|
+
|
71
|
+
|
72
|
+
if not sanitized_segment:
|
73
|
+
return "default_api"
|
74
|
+
if sanitized_segment.isdigit(): # e.g. if path was /2/123 -> segment is 123
|
75
|
+
return f"api_{sanitized_segment}"
|
76
|
+
|
77
|
+
return sanitized_segment
|
78
|
+
|
79
|
+
def get_group_name_from_path(openapi_path: str) -> str:
|
80
|
+
processed_path = openapi_path
|
81
|
+
|
82
|
+
# Pattern for /vN, /vN.N, /vN.N.N
|
83
|
+
version_pattern_v_prefix = re.compile(r"^/v[0-9]+(?:\\.[0-9]+){0,2}")
|
84
|
+
# Pattern for just /N (like /2)
|
85
|
+
version_pattern_numeric_prefix = re.compile(r"^/[0-9]+")
|
86
|
+
|
87
|
+
api_prefix_pattern = re.compile(r"^/api")
|
88
|
+
|
89
|
+
# Strip /api prefix first if present
|
90
|
+
if api_prefix_pattern.match(processed_path):
|
91
|
+
processed_path = api_prefix_pattern.sub("", processed_path)
|
92
|
+
processed_path = processed_path.lstrip("/") # Ensure we strip leading slash if api was the only thing
|
93
|
+
if processed_path and not processed_path.startswith("/"):
|
94
|
+
processed_path = "/" + processed_path
|
95
|
+
elif not processed_path: # Path was only /api/
|
96
|
+
processed_path = "/" # Reset to / so subsequent logic doesn't fail
|
97
|
+
|
98
|
+
# Try to strip /vN style version
|
99
|
+
path_after_v_version_strip = version_pattern_v_prefix.sub("", processed_path)
|
100
|
+
|
101
|
+
if path_after_v_version_strip != processed_path: # /vN was stripped
|
102
|
+
processed_path = path_after_v_version_strip
|
103
|
+
else: # /vN was not found, try to strip /N (like /2/)
|
104
|
+
path_after_numeric_version_strip = version_pattern_numeric_prefix.sub("", processed_path)
|
105
|
+
if path_after_numeric_version_strip != processed_path: # /N was stripped
|
106
|
+
processed_path = path_after_numeric_version_strip
|
107
|
+
|
108
|
+
processed_path = processed_path.lstrip("/")
|
109
|
+
|
110
|
+
path_segments = [segment for segment in processed_path.split("/") if segment] # Ensure no empty segments
|
111
|
+
|
112
|
+
group_name_raw = "default"
|
113
|
+
if path_segments and path_segments[0]:
|
114
|
+
# Remove {param} style parts from the segment if any
|
115
|
+
group_name_raw = re.sub(r"[{}]", "", path_segments[0]) # Corrected regex string
|
116
|
+
|
117
|
+
# Sanitize to make it a valid Python identifier component (lowercase, underscores)
|
118
|
+
group_name = re.sub(r"[^a-zA-Z0-9_]", "_", group_name_raw).lower() # Corrected regex string
|
119
|
+
group_name = group_name.strip("_")
|
120
|
+
|
121
|
+
if not group_name or group_name.isdigit(): # If empty after sanitization or purely numeric
|
122
|
+
group_name = f"api_{group_name}" if group_name else "default_api"
|
123
|
+
|
124
|
+
if iskeyword(group_name): # Avoid Python keywords
|
125
|
+
group_name += "_"
|
126
|
+
|
127
|
+
return group_name if group_name else "default_api"
|
128
|
+
|
129
|
+
|
130
|
+
class MethodTransformer(ast.NodeTransformer):
|
131
|
+
def __init__(self, original_path: str):
|
132
|
+
self.original_path = original_path
|
133
|
+
|
134
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.FunctionDef:
|
135
|
+
# All logic related to adding headers parameter has been removed.
|
136
|
+
self.generic_visit(node)
|
137
|
+
return node
|
138
|
+
|
139
|
+
def visit_Attribute(self, node: ast.Attribute) -> ast.AST:
|
140
|
+
if isinstance(node.value, ast.Name) and node.value.id == 'self' and node.attr == 'base_url':
|
141
|
+
return ast.Attribute(
|
142
|
+
value=ast.Attribute(value=ast.Name(id='self', ctx=ast.Load()), attr='main_app_client', ctx=ast.Load()),
|
143
|
+
attr='base_url',
|
144
|
+
ctx=ast.Load()
|
145
|
+
)
|
146
|
+
return self.generic_visit(node)
|
147
|
+
|
148
|
+
def visit_Call(self, node: ast.Call) -> ast.Call:
|
149
|
+
return self.generic_visit(node)
|
150
|
+
|
151
|
+
|
152
|
+
def split_generated_app_file(input_app_file: Path, output_dir: Path):
|
153
|
+
content = input_app_file.read_text()
|
154
|
+
tree = ast.parse(content)
|
155
|
+
|
156
|
+
main_app_class_node = None
|
157
|
+
for node_item in tree.body: # Renamed to avoid conflict with ast.Node
|
158
|
+
if isinstance(node_item, ast.ClassDef) and \
|
159
|
+
any(isinstance(base, ast.Name) and base.id == 'APIApplication' for base in node_item.bases if isinstance(base, ast.Name)):
|
160
|
+
main_app_class_node = node_item
|
161
|
+
break
|
162
|
+
|
163
|
+
if not main_app_class_node:
|
164
|
+
raise ValueError("Could not find main APIApplication class in the input file.")
|
165
|
+
|
166
|
+
grouped_methods = defaultdict(list)
|
167
|
+
other_main_app_body_nodes = []
|
168
|
+
processed_method_names_in_main = set()
|
169
|
+
|
170
|
+
openapi_path_regex = re.compile(r"# openapi_path: (.+)")
|
171
|
+
|
172
|
+
for item in main_app_class_node.body:
|
173
|
+
if isinstance(item, ast.FunctionDef):
|
174
|
+
path_from_comment = None
|
175
|
+
if item.body and isinstance(item.body[0], ast.Expr) and \
|
176
|
+
isinstance(item.body[0].value, ast.Constant) and isinstance(item.body[0].value.value, str):
|
177
|
+
docstring_lines = item.body[0].value.value.strip().splitlines()
|
178
|
+
if docstring_lines:
|
179
|
+
match = openapi_path_regex.match(docstring_lines[0].strip())
|
180
|
+
if match:
|
181
|
+
path_from_comment = match.group(1).strip()
|
182
|
+
|
183
|
+
if path_from_comment:
|
184
|
+
group = get_group_name_from_path(path_from_comment)
|
185
|
+
method_node_copy = ast.parse(ast.unparse(item)).body[0]
|
186
|
+
if not isinstance(method_node_copy, ast.FunctionDef):
|
187
|
+
method_node_copy = item
|
188
|
+
|
189
|
+
transformer = MethodTransformer(original_path=path_from_comment)
|
190
|
+
transformed_method_node = transformer.visit(method_node_copy)
|
191
|
+
if hasattr(ast, 'fix_missing_locations'):
|
192
|
+
transformed_method_node = ast.fix_missing_locations(transformed_method_node)
|
193
|
+
|
194
|
+
# Remove the # openapi_path: comment from the docstring
|
195
|
+
if (transformed_method_node.body and
|
196
|
+
isinstance(transformed_method_node.body[0], ast.Expr) and
|
197
|
+
isinstance(transformed_method_node.body[0].value, ast.Constant) and
|
198
|
+
isinstance(transformed_method_node.body[0].value.value, str)):
|
199
|
+
|
200
|
+
docstring_expr_node = transformed_method_node.body[0]
|
201
|
+
original_docstring_text = docstring_expr_node.value.value
|
202
|
+
|
203
|
+
all_lines_raw = original_docstring_text.splitlines(True)
|
204
|
+
line_to_remove_idx = -1
|
205
|
+
|
206
|
+
for i, current_line_raw in enumerate(all_lines_raw):
|
207
|
+
current_line_stripped = current_line_raw.strip()
|
208
|
+
if current_line_stripped: # Found the first significant line
|
209
|
+
if openapi_path_regex.match(current_line_stripped):
|
210
|
+
line_to_remove_idx = i
|
211
|
+
break # Only inspect the first significant line
|
212
|
+
|
213
|
+
if line_to_remove_idx != -1:
|
214
|
+
del all_lines_raw[line_to_remove_idx]
|
215
|
+
modified_docstring_text = "".join(all_lines_raw)
|
216
|
+
|
217
|
+
if not modified_docstring_text.strip():
|
218
|
+
transformed_method_node.body.pop(0) # Docstring is now empty
|
219
|
+
else:
|
220
|
+
docstring_expr_node.value.value = modified_docstring_text
|
221
|
+
|
222
|
+
grouped_methods[group].append(transformed_method_node)
|
223
|
+
processed_method_names_in_main.add(item.name)
|
224
|
+
else:
|
225
|
+
other_main_app_body_nodes.append(item)
|
226
|
+
else:
|
227
|
+
other_main_app_body_nodes.append(item)
|
228
|
+
|
229
|
+
# Define segments subfolder
|
230
|
+
segments_foldername = "api_segments"
|
231
|
+
segments_dir = output_dir / segments_foldername
|
232
|
+
segments_dir.mkdir(parents=True, exist_ok=True)
|
233
|
+
|
234
|
+
(segments_dir / "api_segment_base.py").write_text(API_SEGMENT_BASE_CODE)
|
235
|
+
|
236
|
+
segment_class_details_for_main_app = []
|
237
|
+
|
238
|
+
for group, method_nodes in grouped_methods.items():
|
239
|
+
SegmentClassName = "".join(word.capitalize() for word in group.split("_")) + "Api"
|
240
|
+
segment_filename = f"{group.lower()}_api.py"
|
241
|
+
|
242
|
+
method_names_for_list_tools = [method_node.name for method_node in method_nodes]
|
243
|
+
|
244
|
+
list_tools_body = [ast.Return(value=ast.List(
|
245
|
+
elts=[ast.Attribute(value=ast.Name(id='self', ctx=ast.Load()), attr=name, ctx=ast.Load()) for name in method_names_for_list_tools],
|
246
|
+
ctx=ast.Load()
|
247
|
+
))]
|
248
|
+
list_tools_def = ast.FunctionDef(
|
249
|
+
name='list_tools',
|
250
|
+
args=ast.arguments(posonlyargs=[], args=[ast.arg(arg='self')], vararg=None, kwonlyargs=[], kw_defaults=[], kwarg=None, defaults=[]),
|
251
|
+
body=list_tools_body, decorator_list=[], returns=None
|
252
|
+
)
|
253
|
+
|
254
|
+
init_method_segment = ast.FunctionDef(
|
255
|
+
name='__init__',
|
256
|
+
args=ast.arguments(
|
257
|
+
posonlyargs=[],
|
258
|
+
args=[ast.arg(arg='self'), ast.arg(arg='main_app_client', annotation=ast.Name(id='Any', ctx=ast.Load()))],
|
259
|
+
vararg=None, kwonlyargs=[], kw_defaults=[], kwarg=None, defaults=[]
|
260
|
+
),
|
261
|
+
body=[
|
262
|
+
ast.Expr(value=ast.Call(
|
263
|
+
func=ast.Attribute(value=ast.Call(func=ast.Name(id='super', ctx=ast.Load()), args=[], keywords=[]), attr='__init__', ctx=ast.Load()),
|
264
|
+
args=[ast.Name(id='main_app_client', ctx=ast.Load())],
|
265
|
+
keywords=[]
|
266
|
+
))
|
267
|
+
],
|
268
|
+
decorator_list=[], returns=None
|
269
|
+
)
|
270
|
+
|
271
|
+
segment_class_body = [init_method_segment] + method_nodes + [list_tools_def]
|
272
|
+
segment_class_node = ast.ClassDef(
|
273
|
+
name=SegmentClassName,
|
274
|
+
bases=[ast.Name(id='APISegmentBase', ctx=ast.Load())],
|
275
|
+
keywords=[], body=segment_class_body, decorator_list=[]
|
276
|
+
)
|
277
|
+
|
278
|
+
segment_module_body = [
|
279
|
+
ast.ImportFrom(module='typing', names=[ast.alias(name='Any'), ast.alias(name='Dict'), ast.alias(name='Optional')], level=0),
|
280
|
+
ast.ImportFrom(module='.api_segment_base', names=[ast.alias(name='APISegmentBase')], level=0), # This relative import is fine as they are in the same dir
|
281
|
+
segment_class_node
|
282
|
+
]
|
283
|
+
segment_module_ast = ast.Module(body=segment_module_body, type_ignores=[])
|
284
|
+
if hasattr(ast, 'fix_missing_locations'):
|
285
|
+
segment_module_ast = ast.fix_missing_locations(segment_module_ast)
|
286
|
+
|
287
|
+
(segments_dir / segment_filename).write_text(ast.unparse(segment_module_ast))
|
288
|
+
segment_class_details_for_main_app.append({
|
289
|
+
"attr_name": group.lower(),
|
290
|
+
"class_name": SegmentClassName,
|
291
|
+
"module_name": segment_filename.replace(".py", "") # Used for import in main app
|
292
|
+
})
|
293
|
+
|
294
|
+
new_main_app_body = []
|
295
|
+
main_app_init_node = None
|
296
|
+
|
297
|
+
for node in other_main_app_body_nodes:
|
298
|
+
if isinstance(node, ast.FunctionDef):
|
299
|
+
if node.name == '__init__':
|
300
|
+
main_app_init_node = node
|
301
|
+
continue
|
302
|
+
elif node.name == 'list_tools':
|
303
|
+
continue
|
304
|
+
new_main_app_body.append(node)
|
305
|
+
|
306
|
+
if not main_app_init_node:
|
307
|
+
main_app_init_node = ast.FunctionDef(
|
308
|
+
name='__init__',
|
309
|
+
args=ast.arguments(
|
310
|
+
posonlyargs=[],
|
311
|
+
args=[ast.arg(arg='self'), ast.arg(arg='integration', annotation=ast.Name(id='Integration', ctx=ast.Load()), default=ast.Constant(value=None))],
|
312
|
+
vararg=ast.arg(arg='args'),
|
313
|
+
kwonlyargs=[], kw_defaults=[],
|
314
|
+
kwarg=ast.arg(arg='kwargs'),
|
315
|
+
defaults=[ast.Constant(value=None)]
|
316
|
+
),
|
317
|
+
body=[
|
318
|
+
ast.Expr(value=ast.Call(
|
319
|
+
func=ast.Attribute(value=ast.Call(func=ast.Name(id='super', ctx=ast.Load()), args=[], keywords=[]), attr='__init__', ctx=ast.Load()),
|
320
|
+
args=[ast.keyword(arg='name', value=ast.Constant(value=main_app_class_node.name.lower())), ast.Name(id='integration', ctx=ast.Load())],
|
321
|
+
keywords=[ast.keyword(arg=None, value=ast.Name(id='kwargs',ctx=ast.Load()))]
|
322
|
+
))
|
323
|
+
],
|
324
|
+
decorator_list=[], returns=ast.Constant(value=None)
|
325
|
+
)
|
326
|
+
|
327
|
+
init_segment_instantiations = []
|
328
|
+
for seg_detail in segment_class_details_for_main_app:
|
329
|
+
init_segment_instantiations.append(
|
330
|
+
ast.Assign(
|
331
|
+
targets=[ast.Attribute(value=ast.Name(id='self', ctx=ast.Load()), attr=seg_detail["attr_name"], ctx=ast.Store())],
|
332
|
+
value=ast.Call(func=ast.Name(id=seg_detail["class_name"], ctx=ast.Load()), args=[ast.Name(id='self', ctx=ast.Load())], keywords=[])
|
333
|
+
)
|
334
|
+
)
|
335
|
+
if not isinstance(main_app_init_node.body, list):
|
336
|
+
main_app_init_node.body = [main_app_init_node.body] # type: ignore
|
337
|
+
|
338
|
+
main_app_init_node.body.extend(init_segment_instantiations)
|
339
|
+
new_main_app_body.insert(0, main_app_init_node)
|
340
|
+
|
341
|
+
list_tools_calls_for_main = [
|
342
|
+
ast.Call(
|
343
|
+
func=ast.Attribute(value=ast.Attribute(value=ast.Name(id='self', ctx=ast.Load()), attr=seg_detail["attr_name"], ctx=ast.Load()), attr='list_tools', ctx=ast.Load()),
|
344
|
+
args=[], keywords=[]
|
345
|
+
) for seg_detail in segment_class_details_for_main_app
|
346
|
+
]
|
347
|
+
|
348
|
+
new_list_tools_body = [ast.Assign(targets=[ast.Name(id='all_tools', ctx=ast.Store())], value=ast.List(elts=[], ctx=ast.Load()))]
|
349
|
+
if list_tools_calls_for_main:
|
350
|
+
for call_node in list_tools_calls_for_main:
|
351
|
+
new_list_tools_body.append(ast.Expr(value=ast.Call(
|
352
|
+
func=ast.Attribute(value=ast.Name(id='all_tools', ctx=ast.Load()), attr='extend', ctx=ast.Load()),
|
353
|
+
args=[call_node], keywords=[]))
|
354
|
+
)
|
355
|
+
new_list_tools_body.append(ast.Return(value=ast.Name(id='all_tools', ctx=ast.Load())))
|
356
|
+
|
357
|
+
new_main_app_list_tools_def = ast.FunctionDef(
|
358
|
+
name='list_tools',
|
359
|
+
args=ast.arguments(posonlyargs=[], args=[ast.arg(arg='self')], vararg=None, kwonlyargs=[], kw_defaults=[], kwarg=None, defaults=[]),
|
360
|
+
body=new_list_tools_body, decorator_list=[], returns=None
|
361
|
+
)
|
362
|
+
new_main_app_body.append(new_main_app_list_tools_def)
|
363
|
+
|
364
|
+
main_app_class_node.body = new_main_app_body
|
365
|
+
|
366
|
+
final_main_module_imports = []
|
367
|
+
other_top_level_nodes_for_main = []
|
368
|
+
original_imports_from_main_file = set()
|
369
|
+
|
370
|
+
for top_node in tree.body:
|
371
|
+
if top_node == main_app_class_node:
|
372
|
+
continue
|
373
|
+
if isinstance(top_node, ast.Import | ast.ImportFrom):
|
374
|
+
final_main_module_imports.append(top_node)
|
375
|
+
if isinstance(top_node, ast.ImportFrom):
|
376
|
+
if top_node.module: # module can be None for from . import x
|
377
|
+
original_imports_from_main_file.add(top_node.module)
|
378
|
+
elif isinstance(top_node, ast.Import):
|
379
|
+
for alias in top_node.names:
|
380
|
+
original_imports_from_main_file.add(alias.name)
|
381
|
+
else:
|
382
|
+
other_top_level_nodes_for_main.append(top_node)
|
383
|
+
|
384
|
+
if "typing" not in original_imports_from_main_file:
|
385
|
+
final_main_module_imports.insert(0, ast.ImportFrom(module='typing', names=[ast.alias(name='Any'), ast.alias(name='Dict'), ast.alias(name='Optional')], level=0))
|
386
|
+
|
387
|
+
for seg_detail in segment_class_details_for_main_app:
|
388
|
+
# Adjust import path for segments subfolder
|
389
|
+
final_main_module_imports.append(
|
390
|
+
ast.ImportFrom(module=f'.{segments_foldername}.{seg_detail["module_name"]}', names=[ast.alias(name=seg_detail["class_name"])], level=0)
|
391
|
+
)
|
392
|
+
|
393
|
+
final_main_app_module_ast = ast.Module(body=final_main_module_imports + other_top_level_nodes_for_main + [main_app_class_node], type_ignores=[])
|
394
|
+
if hasattr(ast, 'fix_missing_locations'):
|
395
|
+
final_main_app_module_ast = ast.fix_missing_locations(final_main_app_module_ast)
|
396
|
+
|
397
|
+
(output_dir / "app.py").write_text(ast.unparse(final_main_app_module_ast))
|
398
|
+
|
399
|
+
(output_dir / "__init__.py").touch(exist_ok=True)
|
400
|
+
(segments_dir / "__init__.py").touch(exist_ok=True)
|