devdox-ai-locust 0.1.1__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 devdox-ai-locust might be problematic. Click here for more details.
- devdox_ai_locust/__init__.py +9 -0
- devdox_ai_locust/cli.py +452 -0
- devdox_ai_locust/config.py +24 -0
- devdox_ai_locust/hybrid_loctus_generator.py +904 -0
- devdox_ai_locust/locust_generator.py +732 -0
- devdox_ai_locust/py.typed +0 -0
- devdox_ai_locust/schemas/__init__.py +0 -0
- devdox_ai_locust/schemas/processing_result.py +24 -0
- devdox_ai_locust/templates/base_workflow.py.j2 +180 -0
- devdox_ai_locust/templates/config.py.j2 +173 -0
- devdox_ai_locust/templates/custom_flows.py.j2 +95 -0
- devdox_ai_locust/templates/endpoint_template.py.j2 +34 -0
- devdox_ai_locust/templates/env.example.j2 +3 -0
- devdox_ai_locust/templates/fallback_locust.py.j2 +25 -0
- devdox_ai_locust/templates/locust.py.j2 +70 -0
- devdox_ai_locust/templates/readme.md.j2 +46 -0
- devdox_ai_locust/templates/requirement.txt.j2 +31 -0
- devdox_ai_locust/templates/test_data.py.j2 +276 -0
- devdox_ai_locust/templates/utils.py.j2 +335 -0
- devdox_ai_locust/utils/__init__.py +0 -0
- devdox_ai_locust/utils/file_creation.py +120 -0
- devdox_ai_locust/utils/open_ai_parser.py +431 -0
- devdox_ai_locust/utils/swagger_utils.py +94 -0
- devdox_ai_locust-0.1.1.dist-info/METADATA +424 -0
- devdox_ai_locust-0.1.1.dist-info/RECORD +29 -0
- devdox_ai_locust-0.1.1.dist-info/WHEEL +5 -0
- devdox_ai_locust-0.1.1.dist-info/entry_points.txt +3 -0
- devdox_ai_locust-0.1.1.dist-info/licenses/LICENSE +201 -0
- devdox_ai_locust-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Locust Test Generator
|
|
3
|
+
|
|
4
|
+
Generates Locust performance test files from parsed OpenAPI endpoints.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import secrets
|
|
10
|
+
from typing import List, Tuple, Dict, Any, Optional
|
|
11
|
+
import black
|
|
12
|
+
from jinja2 import Environment, FileSystemLoader
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import logging
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
from devdox_ai_locust.utils.open_ai_parser import Endpoint, Parameter
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class TestDataConfig:
|
|
26
|
+
"""Configuration for test data generation"""
|
|
27
|
+
|
|
28
|
+
string_length: int = 10
|
|
29
|
+
integer_min: int = 1
|
|
30
|
+
integer_max: int = 1000
|
|
31
|
+
array_size: int = 3
|
|
32
|
+
use_realistic_data: bool = True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LocustTestGenerator:
|
|
36
|
+
"""Generates Locust performance test files from OpenAPI endpoints"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
test_config: Optional[TestDataConfig] = None,
|
|
41
|
+
jinja_env: Optional[Environment] = None,
|
|
42
|
+
):
|
|
43
|
+
self.test_config = test_config or TestDataConfig()
|
|
44
|
+
self.generated_files: Dict[str, str] = {}
|
|
45
|
+
self.auth_token = None
|
|
46
|
+
self.user_data: Dict[str, Any] = {}
|
|
47
|
+
self.request_count = 0
|
|
48
|
+
|
|
49
|
+
self.template_dir = self._find_project_root() / "templates"
|
|
50
|
+
|
|
51
|
+
self.template_dir.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
self.jinja_env = jinja_env or self._setup_templates()
|
|
54
|
+
|
|
55
|
+
def _find_project_root(self) -> Path:
|
|
56
|
+
"""Find the project root by looking for setup.py, pyproject.toml, or .git"""
|
|
57
|
+
current_path = Path(__file__).parent
|
|
58
|
+
|
|
59
|
+
return current_path
|
|
60
|
+
|
|
61
|
+
def _setup_templates(self) -> Environment:
|
|
62
|
+
"""Initialize Jinja2 environment and create default templates"""
|
|
63
|
+
return Environment(
|
|
64
|
+
loader=FileSystemLoader(self.template_dir),
|
|
65
|
+
trim_blocks=True,
|
|
66
|
+
lstrip_blocks=True,
|
|
67
|
+
keep_trailing_newline=True,
|
|
68
|
+
autoescape=False,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def fix_indent(self, base_files: Dict[str, str]) -> Dict[str, str]:
|
|
72
|
+
"""Fix indentation for generated files"""
|
|
73
|
+
try:
|
|
74
|
+
mode = black.Mode()
|
|
75
|
+
updated_data = {}
|
|
76
|
+
|
|
77
|
+
for key, value in base_files.items():
|
|
78
|
+
try:
|
|
79
|
+
formatted_code = black.format_str(value, mode=mode)
|
|
80
|
+
|
|
81
|
+
updated_data[key] = formatted_code
|
|
82
|
+
except black.InvalidInput:
|
|
83
|
+
# Not valid Python code, keep original
|
|
84
|
+
logger.debug(f"Skipping formatting for {key}: not valid Python")
|
|
85
|
+
updated_data[key] = value
|
|
86
|
+
except Exception as format_error:
|
|
87
|
+
# Other Black formatting errors, keep original and log
|
|
88
|
+
logger.warning(f"Failed to format {key}: {format_error}")
|
|
89
|
+
updated_data[key] = value
|
|
90
|
+
|
|
91
|
+
return updated_data
|
|
92
|
+
|
|
93
|
+
except Exception as e:
|
|
94
|
+
logger.error(f"exception occurred: {e}")
|
|
95
|
+
return base_files
|
|
96
|
+
|
|
97
|
+
def generate_from_endpoints(
|
|
98
|
+
self,
|
|
99
|
+
endpoints: List[Endpoint],
|
|
100
|
+
api_info: Dict[str, Any],
|
|
101
|
+
include_auth: bool = True,
|
|
102
|
+
target_host: Optional[str] = None,
|
|
103
|
+
) -> Tuple[Dict[str, str], List[Dict[str, Any]], Dict[str, List[Endpoint]]]:
|
|
104
|
+
"""
|
|
105
|
+
Generate complete Locust test suite from parsed endpoints
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
endpoints: List of parsed Endpoint objects
|
|
109
|
+
api_info: API information dictionary
|
|
110
|
+
output_dir: Output directory for generated files
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Dictionary of filename -> file content
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
grouped_enpoint = self._group_endpoints_by_tag(endpoints, include_auth)
|
|
117
|
+
|
|
118
|
+
workflows_files = self.generate_workflows(grouped_enpoint, api_info)
|
|
119
|
+
|
|
120
|
+
self.generated_files = {
|
|
121
|
+
"locustfile.py": self._generate_main_locustfile(
|
|
122
|
+
endpoints, api_info, list(grouped_enpoint.keys())
|
|
123
|
+
),
|
|
124
|
+
"test_data.py": self._generate_test_data_file(),
|
|
125
|
+
"config.py": self._generate_config_file(api_info),
|
|
126
|
+
"utils.py": self._generate_utils_file(),
|
|
127
|
+
"custom_flows.py": self._generate_custom_flows_file(),
|
|
128
|
+
"requirements.txt": self._generate_requirements_file(),
|
|
129
|
+
"README.md": self._generate_readme_file(api_info),
|
|
130
|
+
".env.example": self._generate_env_example(api_info, target_host),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return self.generated_files, workflows_files, grouped_enpoint
|
|
134
|
+
except Exception as e:
|
|
135
|
+
logger.error(f"Failed to generate test suite: {e}")
|
|
136
|
+
empty_files: Dict[str, str] = {}
|
|
137
|
+
empty_workflows: List[Dict[str, Any]] = []
|
|
138
|
+
empty_grouped: Dict[str, List[Endpoint]] = {}
|
|
139
|
+
return empty_files, empty_workflows, empty_grouped
|
|
140
|
+
|
|
141
|
+
def generate_workflows(
|
|
142
|
+
self, endpoints: Dict[str, List[Any]], api_info: Dict[str, Any]
|
|
143
|
+
) -> List[Dict[str, Any]]:
|
|
144
|
+
"""
|
|
145
|
+
Generate the workflows Locust test files with proper structure and no duplicates
|
|
146
|
+
"""
|
|
147
|
+
try:
|
|
148
|
+
workflows: List = []
|
|
149
|
+
|
|
150
|
+
for group, group_endpoints in endpoints.items():
|
|
151
|
+
task_methods: List[str] = []
|
|
152
|
+
|
|
153
|
+
for endpoint in group_endpoints:
|
|
154
|
+
try:
|
|
155
|
+
task_method = self._generate_task_method(endpoint)
|
|
156
|
+
if task_method:
|
|
157
|
+
task_methods.append(task_method)
|
|
158
|
+
except Exception as e:
|
|
159
|
+
logger.warning(
|
|
160
|
+
f"⚠️ Failed to generate task method for {getattr(endpoint, 'path', '?')}: {e}"
|
|
161
|
+
)
|
|
162
|
+
continue
|
|
163
|
+
if not task_methods:
|
|
164
|
+
logger.warning(f"No task methods generated from group {group}")
|
|
165
|
+
task_methods.append(self._generate_default_task_method())
|
|
166
|
+
indented_task_methods = self._indent_methods(
|
|
167
|
+
task_methods, indent_level=1
|
|
168
|
+
)
|
|
169
|
+
file_content = self._build_endpoint_template(
|
|
170
|
+
api_info, indented_task_methods, group
|
|
171
|
+
)
|
|
172
|
+
file_name = f"{group}_workflow.py".replace("-", "_")
|
|
173
|
+
|
|
174
|
+
workflows.append({file_name: file_content})
|
|
175
|
+
|
|
176
|
+
workflows.append(
|
|
177
|
+
{"base_workflow.py": self.generate_base_common_file(api_info)}
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return workflows
|
|
181
|
+
|
|
182
|
+
except Exception as e:
|
|
183
|
+
logger.error(f"❌ Failed to generate test suite: {e}")
|
|
184
|
+
|
|
185
|
+
return []
|
|
186
|
+
|
|
187
|
+
def _build_endpoint_template(
|
|
188
|
+
self, api_info: Dict[str, Any], task_methods_content: str, group: str
|
|
189
|
+
) -> str:
|
|
190
|
+
template = self.jinja_env.get_template("endpoint_template.py.j2")
|
|
191
|
+
return template.render(
|
|
192
|
+
api_info=api_info, group=group, task_methods_content=task_methods_content
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def _generate_main_locustfile(
|
|
196
|
+
self, endpoints: List[Any], api_info: Dict[str, Any], groups: List[str]
|
|
197
|
+
) -> str:
|
|
198
|
+
"""
|
|
199
|
+
Generate the main Locust test file with proper structure and no duplicates
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
endpoints: List of parsed Endpoint objects
|
|
203
|
+
api_info: API information dictionary
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Complete locustfile.py content as string
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
# Generate task methods for each endpoint
|
|
210
|
+
task_methods = []
|
|
211
|
+
for endpoint in endpoints:
|
|
212
|
+
try:
|
|
213
|
+
task_method = self._generate_task_method(endpoint)
|
|
214
|
+
if task_method:
|
|
215
|
+
task_methods.append(task_method)
|
|
216
|
+
except Exception as e:
|
|
217
|
+
logger.warning(
|
|
218
|
+
f"Failed to generate task method for {endpoint.path}: {e}"
|
|
219
|
+
)
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
if not task_methods:
|
|
223
|
+
logger.warning("No task methods generated from endpoints")
|
|
224
|
+
# Generate a default task method
|
|
225
|
+
task_methods.append(self._generate_default_task_method())
|
|
226
|
+
|
|
227
|
+
# Properly indent task methods for class inclusion
|
|
228
|
+
indented_task_methods = self._indent_methods(task_methods, indent_level=1)
|
|
229
|
+
indented_task_methods = ""
|
|
230
|
+
# Generate the complete file content
|
|
231
|
+
return self._build_locustfile_template(
|
|
232
|
+
api_info=api_info,
|
|
233
|
+
task_methods_content=indented_task_methods,
|
|
234
|
+
groups=groups,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
except Exception as e:
|
|
238
|
+
logger.error(f"Failed to generate test suite: {e}")
|
|
239
|
+
# Return fallback files
|
|
240
|
+
return self._generate_fallback_locustfile(api_info)
|
|
241
|
+
|
|
242
|
+
def _indent_methods(self, task_methods: List[str], indent_level: int = 1) -> str:
|
|
243
|
+
"""Properly indent task methods for class inclusion"""
|
|
244
|
+
indented_methods = []
|
|
245
|
+
for method in task_methods:
|
|
246
|
+
lines = method.split("\n")
|
|
247
|
+
indented_lines = []
|
|
248
|
+
first_nonempty = True
|
|
249
|
+
|
|
250
|
+
for line in lines:
|
|
251
|
+
if line.strip():
|
|
252
|
+
stripped_line = line.lstrip()
|
|
253
|
+
if first_nonempty:
|
|
254
|
+
# First non-empty line → indent once (for @task or def)
|
|
255
|
+
indented_lines.append(" " * indent_level + stripped_line)
|
|
256
|
+
first_nonempty = False
|
|
257
|
+
else:
|
|
258
|
+
# Method body → indent deeper
|
|
259
|
+
indented_lines.append(
|
|
260
|
+
" " * (indent_level + 1) + stripped_line
|
|
261
|
+
)
|
|
262
|
+
else:
|
|
263
|
+
indented_lines.append("")
|
|
264
|
+
indented_methods.append("\n".join(indented_lines))
|
|
265
|
+
|
|
266
|
+
return "\n\n".join(indented_methods)
|
|
267
|
+
|
|
268
|
+
def _generate_fallback_locustfile(self, api_info: Dict[str, Any]) -> str:
|
|
269
|
+
"""Generate a basic fallback locustfile when main generation fails"""
|
|
270
|
+
|
|
271
|
+
template = self.jinja_env.get_template("fallback_locust.py.j2")
|
|
272
|
+
return template.render(api_info=api_info)
|
|
273
|
+
|
|
274
|
+
from typing import Dict, Any
|
|
275
|
+
|
|
276
|
+
def _build_locustfile_template(
|
|
277
|
+
self, api_info: Dict[str, Any], task_methods_content: str, groups: List[str]
|
|
278
|
+
) -> str:
|
|
279
|
+
import_group_tasks = ""
|
|
280
|
+
tasks = []
|
|
281
|
+
for group in groups:
|
|
282
|
+
file_name = group.lower().replace("-", "_")
|
|
283
|
+
class_name = group.replace("-", "")
|
|
284
|
+
import_group_tasks += f"""from workflows.{file_name}_workflow import {class_name}TaskMethods\n"""
|
|
285
|
+
tasks.append(f"{class_name}TaskMethods")
|
|
286
|
+
tasks_str = "[" + ",".join(tasks) + "]"
|
|
287
|
+
template = self.jinja_env.get_template("locust.py.j2")
|
|
288
|
+
|
|
289
|
+
# Prepare template context
|
|
290
|
+
context = {
|
|
291
|
+
"import_group_tasks": import_group_tasks,
|
|
292
|
+
"task_methods_content": task_methods_content,
|
|
293
|
+
"tasks_str": tasks_str,
|
|
294
|
+
"api_info": api_info,
|
|
295
|
+
"generated_task_classes": self._generate_user_classes(),
|
|
296
|
+
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
# Render the template
|
|
300
|
+
content = template.render(**context)
|
|
301
|
+
|
|
302
|
+
logger.info("README generated successfully using template")
|
|
303
|
+
return content
|
|
304
|
+
|
|
305
|
+
def _generate_default_task_method(self) -> str:
|
|
306
|
+
"""Generate a default task method when no endpoints are available"""
|
|
307
|
+
return '''@task(1)
|
|
308
|
+
def default_health_check(self):
|
|
309
|
+
"""Default health check task"""
|
|
310
|
+
try:
|
|
311
|
+
response_data = self.make_request(
|
|
312
|
+
method="get",
|
|
313
|
+
path="/health"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if response_data:
|
|
317
|
+
self._store_response_data("health_check", response_data)
|
|
318
|
+
|
|
319
|
+
except Exception as e:
|
|
320
|
+
logger.error(f"Health check task failed: {e}")
|
|
321
|
+
'''
|
|
322
|
+
|
|
323
|
+
def _generate_task_method(self, endpoint: Any) -> str:
|
|
324
|
+
"""Generate a Locust task method for a single endpoint with improved structure"""
|
|
325
|
+
try:
|
|
326
|
+
method_name = self._generate_method_name(endpoint)
|
|
327
|
+
path_with_params = self._generate_path_with_params(endpoint)
|
|
328
|
+
weight = self._get_task_weight(getattr(endpoint, "method", "GET"))
|
|
329
|
+
|
|
330
|
+
# Build the task method with proper error handling
|
|
331
|
+
task_method = f'''@task({weight})
|
|
332
|
+
def {method_name}(self):
|
|
333
|
+
"""
|
|
334
|
+
{getattr(endpoint, "summary", f"{getattr(endpoint, 'method', 'GET')} {getattr(endpoint, 'path', '')}")}
|
|
335
|
+
{getattr(endpoint, "description", "")}
|
|
336
|
+
"""
|
|
337
|
+
try:
|
|
338
|
+
# Generate path parameters
|
|
339
|
+
{self._generate_path_params_code(endpoint)}
|
|
340
|
+
|
|
341
|
+
# Generate query parameters
|
|
342
|
+
{self._generate_query_params_code(endpoint)}
|
|
343
|
+
|
|
344
|
+
# Generate request body
|
|
345
|
+
{self._generate_request_body_code(endpoint)}
|
|
346
|
+
|
|
347
|
+
# Make the request
|
|
348
|
+
response_data = self.make_request(
|
|
349
|
+
method="{getattr(endpoint, "method", "GET").lower()}",
|
|
350
|
+
path=f"{path_with_params}",
|
|
351
|
+
{self._generate_request_kwargs(endpoint)}
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
if response_data:
|
|
355
|
+
# Store response data for dependent requests
|
|
356
|
+
self._store_response_data("{method_name}", response_data)
|
|
357
|
+
|
|
358
|
+
except Exception as e:
|
|
359
|
+
logger.error(f"Task {method_name} failed: {{e}}")
|
|
360
|
+
'''
|
|
361
|
+
return task_method
|
|
362
|
+
|
|
363
|
+
except Exception as e:
|
|
364
|
+
logger.error(f"Failed to generate task method for 535 endpoint: {e}")
|
|
365
|
+
return ""
|
|
366
|
+
|
|
367
|
+
def _generate_method_name(self, endpoint: Endpoint) -> str:
|
|
368
|
+
"""Generate a valid Python method name from endpoint"""
|
|
369
|
+
if endpoint.operation_id:
|
|
370
|
+
# Use operation ID if available
|
|
371
|
+
name = endpoint.operation_id
|
|
372
|
+
else:
|
|
373
|
+
# Generate name from method and path
|
|
374
|
+
path_parts = [
|
|
375
|
+
part
|
|
376
|
+
for part in endpoint.path.split("/")
|
|
377
|
+
if part and not part.startswith("{")
|
|
378
|
+
]
|
|
379
|
+
name = f"{endpoint.method.lower()}_{'_'.join(path_parts)}"
|
|
380
|
+
|
|
381
|
+
# Clean up the name
|
|
382
|
+
name = re.sub(r"[^\w]", "_", name)
|
|
383
|
+
name = re.sub(r"_+", "_", name)
|
|
384
|
+
name = name.strip("_")
|
|
385
|
+
|
|
386
|
+
return name if name else f"{endpoint.method.lower()}_endpoint"
|
|
387
|
+
|
|
388
|
+
def _generate_path_with_params(self, endpoint: Endpoint) -> str:
|
|
389
|
+
"""Generate path with parameter placeholders"""
|
|
390
|
+
path = endpoint.path
|
|
391
|
+
|
|
392
|
+
# Replace path parameters with f-string format
|
|
393
|
+
for param in endpoint.parameters:
|
|
394
|
+
if param.location.value == "path":
|
|
395
|
+
path = path.replace(f"{{{param.name}}}", f"{{{param.name}}}")
|
|
396
|
+
|
|
397
|
+
return path
|
|
398
|
+
|
|
399
|
+
def _generate_path_params_code(self, endpoint: Endpoint) -> str:
|
|
400
|
+
"""Generate code for path parameters"""
|
|
401
|
+
path_params = [p for p in endpoint.parameters if p.location.value == "path"]
|
|
402
|
+
|
|
403
|
+
if not path_params:
|
|
404
|
+
return "# No path parameters"
|
|
405
|
+
|
|
406
|
+
code_lines = []
|
|
407
|
+
for param in path_params:
|
|
408
|
+
if param.type.startswith("integer"):
|
|
409
|
+
code_lines.append(f"{param.name} = data_generator.generate_integer()")
|
|
410
|
+
elif param.type == "string":
|
|
411
|
+
if "id" in param.name.lower():
|
|
412
|
+
code_lines.append(f"{param.name} = data_generator.generate_id()")
|
|
413
|
+
else:
|
|
414
|
+
code_lines.append(
|
|
415
|
+
f"{param.name} = data_generator.generate_string()"
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
else:
|
|
419
|
+
code_lines.append(
|
|
420
|
+
f'{param.name} = data_generator.generate_value("{param.type}")'
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
return "\n".join(code_lines)
|
|
424
|
+
|
|
425
|
+
def _generate_query_params_code(self, endpoint: Endpoint) -> str:
|
|
426
|
+
"""Generate code for query parameters"""
|
|
427
|
+
query_params = [p for p in endpoint.parameters if p.location.value == "query"]
|
|
428
|
+
|
|
429
|
+
if not query_params:
|
|
430
|
+
return "params = {}"
|
|
431
|
+
|
|
432
|
+
param_lines = []
|
|
433
|
+
for param in query_params:
|
|
434
|
+
if self._should_skip_optional_param(param):
|
|
435
|
+
continue
|
|
436
|
+
|
|
437
|
+
param_line = self._generate_param_line(param)
|
|
438
|
+
param_lines.append(param_line)
|
|
439
|
+
|
|
440
|
+
return self._format_params_dict(param_lines)
|
|
441
|
+
|
|
442
|
+
def _should_skip_optional_param(self, param: Parameter) -> bool:
|
|
443
|
+
"""Randomly skip optional parameters 30% of the time"""
|
|
444
|
+
return not param.required and secrets.randbelow(100) > 70
|
|
445
|
+
|
|
446
|
+
def _generate_param_line(self, param: Parameter) -> str:
|
|
447
|
+
"""Generate a single parameter line based on its type"""
|
|
448
|
+
param_generators = {
|
|
449
|
+
"integer": self._generate_integer_param,
|
|
450
|
+
"string": self._generate_string_param,
|
|
451
|
+
"boolean": self._generate_boolean_param,
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
# Handle integer types that might have prefixes like "integer64"
|
|
455
|
+
param_type = "integer" if param.type.startswith("integer") else param.type
|
|
456
|
+
|
|
457
|
+
generator = param_generators.get(param_type, self._generate_generic_param)
|
|
458
|
+
return generator(param)
|
|
459
|
+
|
|
460
|
+
def _generate_integer_param(self, param: Parameter) -> str:
|
|
461
|
+
"""Generate integer parameter line"""
|
|
462
|
+
default = param.default if param.default is not None else "None"
|
|
463
|
+
return f'"{param.name}": data_generator.generate_integer(default={default}),'
|
|
464
|
+
|
|
465
|
+
def _generate_string_param(self, param: Parameter) -> str:
|
|
466
|
+
"""Generate string parameter line"""
|
|
467
|
+
default = f'"{param.default}"' if param.default else "None"
|
|
468
|
+
return f'"{param.name}": data_generator.generate_string(default={default}),'
|
|
469
|
+
|
|
470
|
+
def _generate_boolean_param(self, param: Parameter) -> str:
|
|
471
|
+
"""Generate boolean parameter line"""
|
|
472
|
+
return f'"{param.name}": data_generator.generate_boolean(),'
|
|
473
|
+
|
|
474
|
+
def _generate_generic_param(self, param: Parameter) -> str:
|
|
475
|
+
"""Generate generic parameter line for unknown types"""
|
|
476
|
+
return f'"{param.name}": data_generator.generate_value("{param.type}"),'
|
|
477
|
+
|
|
478
|
+
def _format_params_dict(self, param_lines: list[str]) -> str:
|
|
479
|
+
"""Format parameter lines into a dictionary structure"""
|
|
480
|
+
if not param_lines:
|
|
481
|
+
return "params = {}"
|
|
482
|
+
|
|
483
|
+
lines = ["params = {"] + [f" {line}" for line in param_lines] + ["}"]
|
|
484
|
+
return "\n".join(lines)
|
|
485
|
+
|
|
486
|
+
def _generate_request_body_code(self, endpoint: Endpoint) -> str:
|
|
487
|
+
"""Generate code for request body"""
|
|
488
|
+
if not endpoint.request_body:
|
|
489
|
+
return "json_data = None"
|
|
490
|
+
|
|
491
|
+
if endpoint.request_body.content_type == "application/json":
|
|
492
|
+
return f"""json_data = data_generator.generate_json_data(
|
|
493
|
+
schema={json.dumps(endpoint.request_body.schema, indent=16)}
|
|
494
|
+
)"""
|
|
495
|
+
elif endpoint.request_body.content_type == "application/x-www-form-urlencoded":
|
|
496
|
+
return "data = data_generator.generate_form_data()"
|
|
497
|
+
else:
|
|
498
|
+
return "json_data = {}"
|
|
499
|
+
|
|
500
|
+
def _generate_request_kwargs(self, endpoint: Endpoint) -> str:
|
|
501
|
+
"""Generate kwargs for the request method"""
|
|
502
|
+
kwargs = []
|
|
503
|
+
|
|
504
|
+
# Add query parameters
|
|
505
|
+
query_params = [p for p in endpoint.parameters if p.location.value == "query"]
|
|
506
|
+
if query_params:
|
|
507
|
+
kwargs.append("params=params")
|
|
508
|
+
|
|
509
|
+
# Add request body
|
|
510
|
+
if endpoint.request_body:
|
|
511
|
+
if endpoint.request_body.content_type == "application/json":
|
|
512
|
+
kwargs.append("json=json_data")
|
|
513
|
+
elif (
|
|
514
|
+
endpoint.request_body.content_type
|
|
515
|
+
== "application/x-www-form-urlencoded"
|
|
516
|
+
):
|
|
517
|
+
kwargs.append("data=data")
|
|
518
|
+
|
|
519
|
+
# Add headers if needed
|
|
520
|
+
header_params = [p for p in endpoint.parameters if p.location.value == "header"]
|
|
521
|
+
if header_params:
|
|
522
|
+
kwargs.append("headers=headers")
|
|
523
|
+
|
|
524
|
+
return ",\n ".join(kwargs)
|
|
525
|
+
|
|
526
|
+
def _get_task_weight(self, method: str) -> int:
|
|
527
|
+
"""Get task weight based on HTTP method"""
|
|
528
|
+
weights = {
|
|
529
|
+
"GET": 5, # Most frequent
|
|
530
|
+
"POST": 2, # Common
|
|
531
|
+
"PUT": 1, # Less frequent
|
|
532
|
+
"PATCH": 1, # Less frequent
|
|
533
|
+
"DELETE": 1, # Least frequent
|
|
534
|
+
"HEAD": 3, # Moderate
|
|
535
|
+
"OPTIONS": 1, # Rare
|
|
536
|
+
}
|
|
537
|
+
return weights.get(method.upper(), 1)
|
|
538
|
+
|
|
539
|
+
def _group_endpoints_by_tag(
|
|
540
|
+
self,
|
|
541
|
+
endpoints: List[Endpoint],
|
|
542
|
+
include_auth_endpoints: bool = True,
|
|
543
|
+
) -> Dict[str, List[Endpoint]]:
|
|
544
|
+
"""Group endpoints by their tags"""
|
|
545
|
+
grouped: Dict[str, List[Endpoint]] = {}
|
|
546
|
+
# Define authentication-related keywords to check for in paths
|
|
547
|
+
auth_keywords = [
|
|
548
|
+
"login",
|
|
549
|
+
"signin",
|
|
550
|
+
"sign-in",
|
|
551
|
+
"sign_in",
|
|
552
|
+
"logout",
|
|
553
|
+
"signout",
|
|
554
|
+
"sign-out",
|
|
555
|
+
"sign_out",
|
|
556
|
+
"auth",
|
|
557
|
+
"authenticate",
|
|
558
|
+
"authorization",
|
|
559
|
+
"token",
|
|
560
|
+
"refresh",
|
|
561
|
+
"verify",
|
|
562
|
+
"password",
|
|
563
|
+
"reset",
|
|
564
|
+
"forgot",
|
|
565
|
+
"register",
|
|
566
|
+
"signup",
|
|
567
|
+
"sign-up",
|
|
568
|
+
"sign_up",
|
|
569
|
+
"session",
|
|
570
|
+
"oauth",
|
|
571
|
+
"sso",
|
|
572
|
+
]
|
|
573
|
+
|
|
574
|
+
def is_auth_endpoint(endpoint_path: str) -> bool:
|
|
575
|
+
"""Check if endpoint path contains authentication-related keywords"""
|
|
576
|
+
path_lower = endpoint_path.lower()
|
|
577
|
+
return any(keyword in path_lower for keyword in auth_keywords)
|
|
578
|
+
|
|
579
|
+
for endpoint in endpoints:
|
|
580
|
+
tags = endpoint.tags if endpoint.tags else ["default"]
|
|
581
|
+
if is_auth_endpoint(endpoint.path) and include_auth_endpoints:
|
|
582
|
+
# Add to authentication group regardless of tags
|
|
583
|
+
if "Authentication" not in grouped:
|
|
584
|
+
grouped["Authentication"] = []
|
|
585
|
+
grouped["Authentication"].append(endpoint)
|
|
586
|
+
|
|
587
|
+
for tag in tags:
|
|
588
|
+
if tag not in grouped:
|
|
589
|
+
grouped[tag] = []
|
|
590
|
+
grouped[tag].append(endpoint)
|
|
591
|
+
|
|
592
|
+
return grouped
|
|
593
|
+
|
|
594
|
+
def _generate_all_task_methods_string(self, endpoints: List[Endpoint]) -> str:
|
|
595
|
+
"""Generate all task methods as a properly indented string"""
|
|
596
|
+
methods = []
|
|
597
|
+
for endpoint in endpoints:
|
|
598
|
+
method_code = self._generate_task_method(endpoint)
|
|
599
|
+
methods.append(method_code)
|
|
600
|
+
|
|
601
|
+
return "\n".join(methods)
|
|
602
|
+
|
|
603
|
+
def _generate_user_classes(self) -> str:
|
|
604
|
+
"""
|
|
605
|
+
**FIXED: Generate user classes with proper structure**
|
|
606
|
+
"""
|
|
607
|
+
|
|
608
|
+
return '''
|
|
609
|
+
class LightUser(BaseAPIUser, BaseTaskMethods):
|
|
610
|
+
"""Light user with occasional API usage patterns"""
|
|
611
|
+
weight = 3
|
|
612
|
+
wait_time = between(3, 8) # Longer wait times
|
|
613
|
+
|
|
614
|
+
def on_start(self):
|
|
615
|
+
super().on_start()
|
|
616
|
+
self.user_type = "light"
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
class RegularUser( BaseAPIUser, BaseTaskMethods):
|
|
620
|
+
"""Regular user with normal API usage patterns"""
|
|
621
|
+
weight = 4
|
|
622
|
+
wait_time = between(1, 4) # Moderate wait times
|
|
623
|
+
|
|
624
|
+
def on_start(self):
|
|
625
|
+
super().on_start()
|
|
626
|
+
self.user_type = "regular"
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
class PowerUser( BaseAPIUser, BaseTaskMethods):
|
|
631
|
+
"""Power user with heavy API usage patterns"""
|
|
632
|
+
weight = 3
|
|
633
|
+
wait_time = between(0.5, 2) # Shorter wait times
|
|
634
|
+
|
|
635
|
+
def on_start(self):
|
|
636
|
+
super().on_start()
|
|
637
|
+
self.user_type = "power"
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
'''
|
|
641
|
+
|
|
642
|
+
def _generate_test_data_file(self) -> str:
|
|
643
|
+
"""Generate test_data.py file content"""
|
|
644
|
+
template = self.jinja_env.get_template("test_data.py.j2")
|
|
645
|
+
return template.render()
|
|
646
|
+
|
|
647
|
+
def _generate_config_file(self, api_info: Dict[str, Any]) -> str:
|
|
648
|
+
"""Generate config.py file content"""
|
|
649
|
+
template = self.jinja_env.get_template("config.py.j2")
|
|
650
|
+
return template.render(api_info=api_info)
|
|
651
|
+
|
|
652
|
+
def _generate_utils_file(self) -> str:
|
|
653
|
+
"""Generate utils.py file content"""
|
|
654
|
+
template = self.jinja_env.get_template("utils.py.j2")
|
|
655
|
+
return template.render()
|
|
656
|
+
|
|
657
|
+
def _generate_custom_flows_file(self) -> str:
|
|
658
|
+
"""Generate custom_flows.py file content"""
|
|
659
|
+
template = self.jinja_env.get_template("custom_flows.py.j2")
|
|
660
|
+
return template.render()
|
|
661
|
+
|
|
662
|
+
def _generate_requirements_file(self) -> str:
|
|
663
|
+
"""Generate requirements.txt file content"""
|
|
664
|
+
template = self.jinja_env.get_template("requirement.txt.j2")
|
|
665
|
+
content = template.render()
|
|
666
|
+
return content
|
|
667
|
+
|
|
668
|
+
def _generate_env_example(
|
|
669
|
+
self, api_info: Dict[str, Any], target_host: Optional[str] = None
|
|
670
|
+
) -> str:
|
|
671
|
+
"""Generate .env.example file content"""
|
|
672
|
+
try:
|
|
673
|
+
template = self.jinja_env.get_template("env.example.j2")
|
|
674
|
+
if target_host:
|
|
675
|
+
base_url = target_host
|
|
676
|
+
locust_host = target_host
|
|
677
|
+
else:
|
|
678
|
+
base_url = api_info.get("base_url", "http://localhost:8000")
|
|
679
|
+
locust_host = api_info.get("base_url", "http://localhost:8000")
|
|
680
|
+
# Prepare environment variables context
|
|
681
|
+
environment_vars = {
|
|
682
|
+
"API_BASE_URL": base_url,
|
|
683
|
+
"API_VERSION": api_info.get("version", "v1"),
|
|
684
|
+
"API_TITLE": api_info.get("title", "Your API Name"),
|
|
685
|
+
"LOCUST_USERS": "50",
|
|
686
|
+
"LOCUST_SPAWN_RATE": "5",
|
|
687
|
+
"LOCUST_RUN_TIME": "10m",
|
|
688
|
+
"LOCUST_HOST": locust_host,
|
|
689
|
+
"USE_REALISTIC_DATA": "true",
|
|
690
|
+
"DATA_SEED": "42",
|
|
691
|
+
"REQUEST_TIMEOUT": "30",
|
|
692
|
+
"MAX_RETRIES": "3",
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
context = {
|
|
696
|
+
"environment_vars": environment_vars,
|
|
697
|
+
"api_info": api_info,
|
|
698
|
+
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
content = template.render(**context)
|
|
702
|
+
logger.info("✅ .env.example generated successfully using template")
|
|
703
|
+
return content
|
|
704
|
+
|
|
705
|
+
except Exception as e:
|
|
706
|
+
logger.error(f"❌ Failed to generate .env.example from template: {e}")
|
|
707
|
+
return ""
|
|
708
|
+
|
|
709
|
+
def _generate_readme_file(self, api_info: Dict[str, Any]) -> str:
|
|
710
|
+
try:
|
|
711
|
+
# Get the template
|
|
712
|
+
template = self.jinja_env.get_template("readme.md.j2")
|
|
713
|
+
|
|
714
|
+
# Prepare template context
|
|
715
|
+
context = {
|
|
716
|
+
"api_info": api_info,
|
|
717
|
+
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
# Render the template
|
|
721
|
+
content = template.render(**context)
|
|
722
|
+
|
|
723
|
+
logger.info("README generated successfully using template")
|
|
724
|
+
return content
|
|
725
|
+
|
|
726
|
+
except Exception as e:
|
|
727
|
+
logger.error(f"Error generating README: {e}")
|
|
728
|
+
return ""
|
|
729
|
+
|
|
730
|
+
def generate_base_common_file(self, api_info: Dict[str, Any]) -> str:
|
|
731
|
+
template = self.jinja_env.get_template("base_workflow.py.j2")
|
|
732
|
+
return template.render(api_info=api_info)
|