universal-mcp 0.1.18rc2__py3-none-any.whl → 0.1.18rc3__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.
@@ -1,4 +1,5 @@
1
1
  import importlib
2
+ import os
2
3
  import subprocess
3
4
  import sys
4
5
  from pathlib import Path
@@ -34,8 +35,12 @@ def _install_or_upgrade_package(package_name: str, repository_path: str):
34
35
  """
35
36
  Helper to install a package via pip from the universal-mcp GitHub repository.
36
37
  """
38
+
39
+ uv_path = os.getenv("UV_PATH")
40
+ uv_executable = str(Path(uv_path) / "uv") if uv_path else "uv"
41
+ logger.info(f"Using uv executable: {uv_executable}")
37
42
  cmd = [
38
- "uv",
43
+ uv_executable,
39
44
  "pip",
40
45
  "install",
41
46
  "--upgrade",
@@ -169,14 +169,28 @@ class APIApplication(BaseApplication):
169
169
  logger.debug(f"GET request successful with status code: {response.status_code}")
170
170
  return response
171
171
 
172
- def _post(self, url: str, data: dict[str, Any], params: dict[str, Any] | None = None) -> httpx.Response:
172
+ def _post(
173
+ self,
174
+ url: str,
175
+ data: Any,
176
+ params: dict[str, Any] | None = None,
177
+ content_type: str = "application/json",
178
+ files: dict[str, Any] | None = None,
179
+ ) -> httpx.Response:
173
180
  """
174
181
  Make a POST request to the specified URL.
175
182
 
176
183
  Args:
177
184
  url: The URL to send the request to
178
- data: The data to send in the request body
185
+ data: The data to send. For 'application/json', this is JSON-serializable.
186
+ For 'application/x-www-form-urlencoded' or 'multipart/form-data', this is a dict of form fields.
187
+ For other content types, this is raw bytes or string.
179
188
  params: Optional query parameters
189
+ content_type: The Content-Type of the request body.
190
+ Examples: 'application/json', 'application/x-www-form-urlencoded',
191
+ 'multipart/form-data', 'application/octet-stream', 'text/plain'.
192
+ files: Optional dictionary of files to upload for 'multipart/form-data'.
193
+ Example: {'file_field_name': ('filename.txt', open('file.txt', 'rb'), 'text/plain')}
180
194
 
181
195
  Returns:
182
196
  httpx.Response: The response from the server
@@ -184,25 +198,69 @@ class APIApplication(BaseApplication):
184
198
  Raises:
185
199
  httpx.HTTPError: If the request fails
186
200
  """
187
- logger.debug(f"Making POST request to {url} with params: {params} and data: {data}")
188
- response = httpx.post(
189
- url,
190
- headers=self._get_headers(),
191
- json=data,
192
- params=params,
201
+ logger.debug(
202
+ f"Making POST request to {url} with params: {params}, data type: {type(data)}, content_type={content_type}, files: {'yes' if files else 'no'}"
193
203
  )
204
+ headers = self._get_headers().copy()
205
+
206
+ if content_type != "multipart/form-data":
207
+ headers["Content-Type"] = content_type
208
+
209
+ if content_type == "multipart/form-data":
210
+ response = self.client.post(
211
+ url,
212
+ headers=headers,
213
+ data=data, # For regular form fields
214
+ files=files, # For file parts
215
+ params=params,
216
+ )
217
+ elif content_type == "application/x-www-form-urlencoded":
218
+ response = self.client.post(
219
+ url,
220
+ headers=headers,
221
+ data=data,
222
+ params=params,
223
+ )
224
+ elif content_type == "application/json":
225
+ response = self.client.post(
226
+ url,
227
+ headers=headers,
228
+ json=data,
229
+ params=params,
230
+ )
231
+ else: # Handles 'application/octet-stream', 'text/plain', 'image/jpeg', etc.
232
+ response = self.client.post(
233
+ url,
234
+ headers=headers,
235
+ content=data, # Expect data to be bytes or str
236
+ params=params,
237
+ )
194
238
  response.raise_for_status()
195
239
  logger.debug(f"POST request successful with status code: {response.status_code}")
196
240
  return response
197
241
 
198
- def _put(self, url: str, data: dict[str, Any], params: dict[str, Any] | None = None) -> httpx.Response:
242
+ def _put(
243
+ self,
244
+ url: str,
245
+ data: Any,
246
+ params: dict[str, Any] | None = None,
247
+ content_type: str = "application/json",
248
+ files: dict[str, Any] | None = None,
249
+ ) -> httpx.Response:
199
250
  """
200
251
  Make a PUT request to the specified URL.
201
252
 
202
253
  Args:
203
254
  url: The URL to send the request to
204
- data: The data to send in the request body
255
+ data: The data to send. For 'application/json', this is JSON-serializable.
256
+ For 'application/x-www-form-urlencoded' or 'multipart/form-data', this is a dict of form fields.
257
+ For other content types, this is raw bytes or string.
205
258
  params: Optional query parameters
259
+ content_type: The Content-Type of the request body.
260
+ Examples: 'application/json', 'application/x-www-form-urlencoded',
261
+ 'multipart/form-data', 'application/octet-stream', 'text/plain'.
262
+ files: Optional dictionary of files to upload for 'multipart/form-data'.
263
+ Example: {'file_field_name': ('filename.txt', open('file.txt', 'rb'), 'text/plain')}
206
264
 
207
265
  Returns:
208
266
  httpx.Response: The response from the server
@@ -210,12 +268,44 @@ class APIApplication(BaseApplication):
210
268
  Raises:
211
269
  httpx.HTTPError: If the request fails
212
270
  """
213
- logger.debug(f"Making PUT request to {url} with params: {params} and data: {data}")
214
- response = self.client.put(
215
- url,
216
- json=data,
217
- params=params,
271
+ logger.debug(
272
+ f"Making PUT request to {url} with params: {params}, data type: {type(data)}, content_type={content_type}, files: {'yes' if files else 'no'}"
218
273
  )
274
+ headers = self._get_headers().copy()
275
+ # For multipart/form-data, httpx handles the Content-Type header (with boundary)
276
+ # For other content types, we set it explicitly.
277
+ if content_type != "multipart/form-data":
278
+ headers["Content-Type"] = content_type
279
+
280
+ if content_type == "multipart/form-data":
281
+ response = self.client.put(
282
+ url,
283
+ headers=headers,
284
+ data=data, # For regular form fields
285
+ files=files, # For file parts
286
+ params=params,
287
+ )
288
+ elif content_type == "application/x-www-form-urlencoded":
289
+ response = self.client.put(
290
+ url,
291
+ headers=headers,
292
+ data=data,
293
+ params=params,
294
+ )
295
+ elif content_type == "application/json":
296
+ response = self.client.put(
297
+ url,
298
+ headers=headers,
299
+ json=data,
300
+ params=params,
301
+ )
302
+ else: # Handles 'application/octet-stream', 'text/plain', 'image/jpeg', etc.
303
+ response = self.client.put(
304
+ url,
305
+ headers=headers,
306
+ content=data, # Expect data to be bytes or str
307
+ params=params,
308
+ )
219
309
  response.raise_for_status()
220
310
  logger.debug(f"PUT request successful with status code: {response.status_code}")
221
311
  return response
universal_mcp/cli.py CHANGED
@@ -45,14 +45,26 @@ def generate(
45
45
  raise typer.Exit(1)
46
46
 
47
47
  try:
48
- # Run the async function in the event loop
49
- app_file = generate_api_from_schema(
48
+ app_file_data = generate_api_from_schema(
50
49
  schema_path=schema_path,
51
50
  output_path=output_path,
52
51
  class_name=class_name,
53
52
  )
54
- console.print("[green]API client successfully generated and installed.[/green]")
55
- console.print(f"[blue]Application file: {app_file}[/blue]")
53
+ if isinstance(app_file_data, dict) and "code" in app_file_data:
54
+ console.print("[yellow]No output path specified, printing generated code to console:[/yellow]")
55
+ console.print(app_file_data["code"])
56
+ elif isinstance(app_file_data, Path):
57
+ console.print("[green]API client successfully generated and installed.[/green]")
58
+ console.print(f"[blue]Application file: {app_file_data}[/blue]")
59
+ else:
60
+ # Handle the error case from api_generator if validation fails
61
+ if isinstance(app_file_data, dict) and "error" in app_file_data:
62
+ console.print(f"[red]{app_file_data['error']}[/red]")
63
+ raise typer.Exit(1)
64
+ else:
65
+ console.print("[red]Unexpected return value from API generator.[/red]")
66
+ raise typer.Exit(1)
67
+
56
68
  except Exception as e:
57
69
  console.print(f"[red]Error generating API client: {e}[/red]")
58
70
  raise typer.Exit(1) from e
@@ -255,5 +267,33 @@ def preprocess(
255
267
  run_preprocessing(schema_path, output_path)
256
268
 
257
269
 
270
+ @app.command()
271
+ def split_api(
272
+ input_app_file: Path = typer.Argument(..., help="Path to the generated app.py file to split"),
273
+ output_dir: Path = typer.Option(..., "--output-dir", "-o", help="Directory to save the split files"),
274
+ ):
275
+ """Splits a single generated API client file into multiple files based on path groups."""
276
+ from universal_mcp.utils.openapi.api_splitter import split_generated_app_file
277
+
278
+ if not input_app_file.exists() or not input_app_file.is_file():
279
+ console.print(f"[red]Error: Input file {input_app_file} does not exist or is not a file.[/red]")
280
+ raise typer.Exit(1)
281
+
282
+ if not output_dir.exists():
283
+ output_dir.mkdir(parents=True, exist_ok=True)
284
+ console.print(f"[green]Created output directory: {output_dir}[/green]")
285
+ elif not output_dir.is_dir():
286
+ console.print(f"[red]Error: Output path {output_dir} is not a directory.[/red]")
287
+ raise typer.Exit(1)
288
+
289
+ try:
290
+ split_generated_app_file(input_app_file, output_dir)
291
+ console.print(f"[green]Successfully split {input_app_file} into {output_dir}[/green]")
292
+ except Exception as e:
293
+ console.print(f"[red]Error splitting API client: {e}[/red]")
294
+
295
+ raise typer.Exit(1) from e
296
+
297
+
258
298
  if __name__ == "__main__":
259
299
  app()
universal_mcp/config.py CHANGED
@@ -49,7 +49,9 @@ class ServerConfig(BaseSettings):
49
49
  description: str = Field(default="Universal MCP", description="Description of the MCP server")
50
50
  api_key: SecretStr | None = Field(default=None, description="API key for authentication")
51
51
  type: Literal["local", "agentr"] = Field(default="agentr", description="Type of server deployment")
52
- transport: Literal["stdio", "sse", "http"] = Field(default="stdio", description="Transport protocol to use")
52
+ transport: Literal["stdio", "sse", "streamable-http"] = Field(
53
+ default="stdio", description="Transport protocol to use"
54
+ )
53
55
  port: int = Field(default=8005, description="Port to run the server on (if applicable)")
54
56
  host: str = Field(default="localhost", description="Host to bind the server to (if applicable)")
55
57
  apps: list[AppConfig] | None = Field(default=None, description="List of configured applications")
@@ -47,7 +47,7 @@ def _generate_mcp_config(api_key: str) -> None:
47
47
  "args": ["universal_mcp@latest", "run"],
48
48
  "env": {
49
49
  "AGENTR_API_KEY": api_key,
50
- "PATH": str(uvx_path.parent),
50
+ "UV_PATH": str(uvx_path.parent),
51
51
  },
52
52
  }
53
53
 
@@ -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)