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.

@@ -0,0 +1,120 @@
1
+ import asyncio
2
+ import shutil
3
+ from pathlib import Path
4
+ from typing import List, Optional
5
+ import uuid
6
+ import os
7
+ import re
8
+ import logging
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class FileCreationConfig:
14
+ """Configuration for file creation process"""
15
+
16
+ ALLOWED_EXTENSIONS = {
17
+ ".py",
18
+ ".md",
19
+ ".txt",
20
+ ".sh",
21
+ ".yml",
22
+ ".yaml",
23
+ ".json",
24
+ ".example",
25
+ }
26
+ MAX_FILE_SIZE = 1024 * 1024 # 1MB
27
+ EXECUTABLE_EXTENSIONS = {".sh"}
28
+
29
+
30
+ class SafeFileCreator:
31
+ """Handles safe file creation with separated concerns"""
32
+
33
+ def __init__(self, config: Optional[FileCreationConfig] = None):
34
+ self.config = config or FileCreationConfig()
35
+
36
+ def _sanitize_filename(self, filename: str) -> str:
37
+ """Sanitize filename to prevent security issues"""
38
+
39
+ # Remove directory components
40
+ clean_name = os.path.basename(filename).lower()
41
+
42
+ # Remove dangerous characters
43
+ clean_name = re.sub(r'[<>:"/\\|?*]', "", clean_name)
44
+ # Replace spaces with underscores
45
+ clean_name = clean_name.replace("- ", "_")
46
+
47
+ # Ensure reasonable length
48
+ if len(clean_name) > 255:
49
+ name_part, ext = os.path.splitext(clean_name)
50
+ clean_name = name_part[:250] + ext
51
+
52
+ # Prevent hidden files and ensure not empty
53
+ safe_dotfiles = {".env.example", ".gitignore", ".env.template"}
54
+ if not clean_name or (
55
+ clean_name.startswith(".") and clean_name not in safe_dotfiles
56
+ ):
57
+ clean_name = f"generated_{uuid.uuid4().hex[:8]}.py"
58
+
59
+ return clean_name
60
+
61
+ def validate_file(self, filename: str, content: str) -> tuple[bool, str, str]:
62
+ """Validate file and return (is_valid, clean_filename, processed_content)"""
63
+ clean_filename = self._sanitize_filename(filename)
64
+ file_extension = Path(clean_filename).suffix.lower()
65
+
66
+ if file_extension not in self.config.ALLOWED_EXTENSIONS:
67
+ logger.warning(f"Skipping file with disallowed extension: {filename}")
68
+ return False, clean_filename, content
69
+
70
+ # Handle oversized files
71
+ if len(content.encode("utf-8")) > self.config.MAX_FILE_SIZE:
72
+ logger.warning(f"File too large, truncating: {filename}")
73
+ content = content[: self.config.MAX_FILE_SIZE // 2]
74
+
75
+ return True, clean_filename, content
76
+
77
+ async def create_temp_file(
78
+ self, filename: str, content: str, temp_dir: Path
79
+ ) -> dict:
80
+ """Create a single file in temp directory"""
81
+ temp_file_path = temp_dir / filename
82
+
83
+ await asyncio.to_thread(temp_file_path.write_text, content, encoding="utf-8")
84
+
85
+ # Set permissions
86
+ file_extension = Path(filename).suffix.lower()
87
+ permissions = (
88
+ 0o755 if file_extension in self.config.EXECUTABLE_EXTENSIONS else 0o644
89
+ )
90
+ temp_file_path.chmod(permissions)
91
+
92
+ return {
93
+ "filename": filename,
94
+ "temp_path": temp_file_path,
95
+ "size": len(content.encode("utf-8")),
96
+ "type": file_extension.lstrip("."),
97
+ }
98
+
99
+ async def move_files_atomically(
100
+ self, file_infos: List[dict], output_path: Path
101
+ ) -> List[dict]:
102
+ """Move all files from temp to final location atomically"""
103
+ successfully_moved = []
104
+
105
+ for file_info in file_infos:
106
+ final_path = output_path / file_info["filename"]
107
+
108
+ try:
109
+ await asyncio.to_thread(
110
+ shutil.move, str(file_info["temp_path"]), str(final_path)
111
+ )
112
+ file_info["final_path"] = final_path
113
+ file_info["path"] = final_path
114
+ successfully_moved.append(file_info)
115
+ logger.info(f"Created: {file_info['filename']}")
116
+
117
+ except Exception as e:
118
+ logger.error(f"Failed to move file {file_info['filename']}: {e}")
119
+
120
+ return successfully_moved
@@ -0,0 +1,431 @@
1
+ """
2
+ OpenAPI 3.x Specification Parser
3
+
4
+ Parses OpenAPI/Swagger specifications and extracts endpoint information
5
+ for automated test generation.
6
+ """
7
+
8
+ import json
9
+ import yaml
10
+ import logging
11
+ from typing import Dict, List, Optional, Any, Union
12
+ from dataclasses import dataclass
13
+ from enum import Enum
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ application_json_type = "application/json"
18
+ localhost_url = "http://localhost"
19
+
20
+
21
+ class ParameterType(Enum):
22
+ QUERY = "query"
23
+ PATH = "path"
24
+ HEADER = "header"
25
+ COOKIE = "cookie"
26
+
27
+
28
+ @dataclass
29
+ class Parameter:
30
+ """Represents an OpenAPI parameter"""
31
+
32
+ name: str
33
+ location: ParameterType
34
+ required: bool
35
+ type: str
36
+ description: Optional[str] = None
37
+ example: Optional[Any] = None
38
+ enum: Optional[List[Any]] = None
39
+ default: Optional[Any] = None
40
+ format: Optional[str] = None
41
+
42
+
43
+ @dataclass
44
+ class RequestBody:
45
+ """Represents an OpenAPI request body"""
46
+
47
+ content_type: str
48
+ schema: Dict[str, Any]
49
+ required: bool = True
50
+ description: Optional[str] = None
51
+ examples: Optional[Dict[str, Any]] = None
52
+
53
+
54
+ @dataclass
55
+ class Response:
56
+ """Represents an OpenAPI response"""
57
+
58
+ status_code: str
59
+ description: str
60
+ content_type: Optional[str] = None
61
+ schema: Optional[Dict[str, Any]] = None
62
+ headers: Optional[Dict[str, Any]] = None
63
+
64
+
65
+ @dataclass
66
+ class Endpoint:
67
+ """Represents a parsed API endpoint"""
68
+
69
+ path: str
70
+ method: str
71
+ operation_id: Optional[str]
72
+ summary: Optional[str]
73
+ description: Optional[str]
74
+ parameters: List[Parameter]
75
+ request_body: Optional[RequestBody]
76
+ responses: List[Response]
77
+ tags: List[str]
78
+ security: Optional[List[Dict[str, Any]]] = None
79
+
80
+
81
+ class OpenAPIParser:
82
+ """Parser for OpenAPI 3.x specifications"""
83
+
84
+ def __init__(self) -> None:
85
+ self.spec_data: Optional[Dict[str, Any]] = None
86
+ self.components: Optional[Dict[str, Any]] = None
87
+
88
+ def parse_schema(self, schema_content: str) -> Dict[str, Any]:
89
+ """
90
+ Parse OpenAPI schema from string content
91
+
92
+ Args:
93
+ schema_content: Raw OpenAPI schema as string (JSON or YAML)
94
+
95
+ Returns:
96
+ Parsed schema dictionary
97
+
98
+ Raises:
99
+ ValueError: If schema is invalid or cannot be parsed
100
+ """
101
+ try:
102
+ # Try parsing as JSON first
103
+ try:
104
+ self.spec_data = json.loads(schema_content)
105
+ except json.JSONDecodeError:
106
+ # If JSON fails, try YAML
107
+ self.spec_data = yaml.safe_load(schema_content)
108
+
109
+ # Validate OpenAPI structure
110
+ self._validate_openapi_schema()
111
+
112
+ # Store components for reference resolution
113
+ self.components = self.spec_data.get("components", {})
114
+
115
+ return self.spec_data
116
+
117
+ except Exception as e:
118
+ logger.error(f"Failed to parse OpenAPI schema: {e}")
119
+ raise ValueError(f"Invalid OpenAPI schema: {e}")
120
+
121
+ def _validate_openapi_schema(self) -> None:
122
+ """Validate that the schema has required OpenAPI structure"""
123
+ if not self.spec_data:
124
+ raise ValueError("No schema data to validate")
125
+
126
+ required_fields = ["openapi", "info", "paths"]
127
+ missing_fields = [
128
+ field for field in required_fields if field not in self.spec_data
129
+ ]
130
+
131
+ if missing_fields:
132
+ raise ValueError(f"Missing required OpenAPI fields: {missing_fields}")
133
+
134
+ # Check OpenAPI version
135
+ version = self.spec_data.get("openapi", "")
136
+ if not version.startswith("3."):
137
+ raise ValueError(
138
+ f"Unsupported OpenAPI version: {version}. Only 3.x is supported."
139
+ )
140
+
141
+ def parse_endpoints(self) -> List[Endpoint]:
142
+ """
143
+ Extract all endpoints from the OpenAPI specification
144
+
145
+ Returns:
146
+ List of parsed Endpoint objects
147
+ """
148
+ if not self.spec_data:
149
+ raise ValueError("Schema must be parsed first. Call parse_schema().")
150
+
151
+ endpoints = []
152
+ paths = self.spec_data.get("paths", {})
153
+
154
+ for path, path_item in paths.items():
155
+ # Skip parameters defined at path level for now
156
+ # (they apply to all operations in the path)
157
+ path_parameters = path_item.get("parameters", [])
158
+
159
+ # Process each HTTP method
160
+ http_methods = [
161
+ "get",
162
+ "post",
163
+ "put",
164
+ "patch",
165
+ "delete",
166
+ "head",
167
+ "options",
168
+ "trace",
169
+ ]
170
+
171
+ for method in http_methods:
172
+ if method in path_item:
173
+ operation = path_item[method]
174
+
175
+ endpoint = Endpoint(
176
+ path=path,
177
+ method=method.upper(),
178
+ operation_id=operation.get("operationId"),
179
+ summary=operation.get("summary"),
180
+ description=operation.get("description"),
181
+ parameters=self._extract_parameters(operation, path_parameters),
182
+ request_body=self._extract_request_body(operation),
183
+ responses=self._extract_responses(operation),
184
+ tags=operation.get("tags", []),
185
+ security=operation.get("security"),
186
+ )
187
+
188
+ endpoints.append(endpoint)
189
+ return endpoints
190
+
191
+ def _extract_parameters(
192
+ self,
193
+ operation: Dict[str, Any],
194
+ path_parameters: Optional[List[Dict[str, Any]]] = None,
195
+ ) -> List[Parameter]:
196
+ """
197
+ Extract parameters from an OpenAPI operation
198
+
199
+ Args:
200
+ operation: OpenAPI operation object
201
+ path_parameters: Parameters defined at path level
202
+
203
+ Returns:
204
+ List of Parameter objects
205
+ """
206
+ parameters = []
207
+
208
+ # Combine path-level and operation-level parameters
209
+ all_params = (path_parameters or []) + operation.get("parameters", [])
210
+
211
+ for param in all_params:
212
+ # Resolve reference if needed
213
+ param = self._resolve_reference(param)
214
+
215
+ if not param:
216
+ continue
217
+
218
+ # Extract parameter type from schema
219
+ param_schema = param.get("schema", {})
220
+ param_type = param_schema.get("type", "string")
221
+ param_format = param_schema.get("format")
222
+
223
+ # Handle array types
224
+ if param_type == "array":
225
+ items = param_schema.get("items", {})
226
+ param_type = f"array[{items.get('type', 'string')}]"
227
+
228
+ parameter = Parameter(
229
+ name=param.get("name", ""),
230
+ location=ParameterType(param.get("in", "query")),
231
+ required=param.get("required", False),
232
+ type=param_type,
233
+ description=param.get("description"),
234
+ example=param.get("example") or param_schema.get("example"),
235
+ enum=param_schema.get("enum"),
236
+ default=param_schema.get("default"),
237
+ format=param_format,
238
+ )
239
+
240
+ parameters.append(parameter)
241
+
242
+ return parameters
243
+
244
+ def _extract_request_body(self, operation: Dict[str, Any]) -> Optional[RequestBody]:
245
+ """
246
+ Extract request body from an OpenAPI operation
247
+
248
+ Args:
249
+ operation: OpenAPI operation object
250
+
251
+ Returns:
252
+ RequestBody object or None if no request body
253
+ """
254
+ request_body_def = operation.get("requestBody")
255
+ if not request_body_def:
256
+ return None
257
+
258
+ # Resolve reference if needed
259
+ request_body_def = self._resolve_reference(request_body_def)
260
+ if not request_body_def:
261
+ return None
262
+
263
+ content = request_body_def.get("content", {})
264
+ if not content:
265
+ return None
266
+
267
+ # Get the first content type (prioritize JSON)
268
+ content_types = list(content.keys())
269
+ preferred_types = [
270
+ application_json_type,
271
+ "application/x-www-form-urlencoded",
272
+ "multipart/form-data",
273
+ ]
274
+
275
+ content_type = None
276
+ for preferred in preferred_types:
277
+ if preferred in content_types:
278
+ content_type = preferred
279
+ break
280
+
281
+ if not content_type:
282
+ content_type = content_types[0]
283
+
284
+ media_type = content[content_type]
285
+ schema = media_type.get("schema", {})
286
+
287
+ # Resolve schema reference if needed
288
+ schema = self._resolve_reference(schema)
289
+
290
+ return RequestBody(
291
+ content_type=content_type,
292
+ schema=schema or {},
293
+ required=request_body_def.get("required", True),
294
+ description=request_body_def.get("description"),
295
+ examples=media_type.get("examples"),
296
+ )
297
+
298
+ def _extract_responses(self, operation: Dict[str, Any]) -> List[Response]:
299
+ """
300
+ Extract responses from an OpenAPI operation
301
+
302
+ Args:
303
+ operation: OpenAPI operation object
304
+
305
+ Returns:
306
+ List of Response objects
307
+ """
308
+ responses = []
309
+ responses_def = operation.get("responses", {})
310
+
311
+ for status_code, response_def in responses_def.items():
312
+ # Resolve reference if needed
313
+ response_def = self._resolve_reference(response_def)
314
+ if not response_def:
315
+ continue
316
+
317
+ # Extract content information
318
+ content = response_def.get("content", {})
319
+ content_type = None
320
+ schema = None
321
+
322
+ if content:
323
+ # Prioritize JSON content type
324
+ if application_json_type in content:
325
+ content_type = application_json_type
326
+ media_type = content[application_json_type]
327
+ else:
328
+ content_type = list(content.keys())[0]
329
+ media_type = content[content_type]
330
+
331
+ schema = media_type.get("schema")
332
+ if schema:
333
+ schema = self._resolve_reference(schema)
334
+
335
+ response = Response(
336
+ status_code=status_code,
337
+ description=response_def.get("description", ""),
338
+ content_type=content_type,
339
+ schema=schema,
340
+ headers=response_def.get("headers"),
341
+ )
342
+
343
+ responses.append(response)
344
+
345
+ return responses
346
+
347
+ def _resolve_reference(
348
+ self, obj: Union[Dict[str, Any], str]
349
+ ) -> Optional[Dict[str, Any]]:
350
+ """
351
+ Resolve OpenAPI reference ($ref) to actual object
352
+
353
+ Args:
354
+ obj: Object that might contain a $ref
355
+
356
+ Returns:
357
+ Resolved object or None if resolution fails
358
+ """
359
+ if not isinstance(obj, dict):
360
+ return None
361
+
362
+ ref = obj.get("$ref")
363
+ if not ref:
364
+ return obj
365
+
366
+ # Parse reference path (e.g., "#/components/schemas/User")
367
+ if not ref.startswith("#/"):
368
+ logger.warning(f"External references not supported: {ref}")
369
+ return None
370
+
371
+ try:
372
+ # Split path and navigate through the spec
373
+ path_parts = ref[2:].split("/") # Remove "#/" prefix
374
+ current = self.spec_data
375
+ if current is None:
376
+ return None
377
+
378
+ for part in path_parts:
379
+ if not isinstance(current, dict):
380
+ return None
381
+ current = current.get(part)
382
+ if current is None:
383
+ return None
384
+
385
+ return current
386
+
387
+ except (KeyError, TypeError) as e:
388
+ logger.warning(f"Failed to resolve reference {ref}: {e}")
389
+ return None
390
+
391
+ def get_schema_info(self) -> Dict[str, Any]:
392
+ """
393
+ Get basic information about the API
394
+
395
+ Returns:
396
+ Dictionary with API information
397
+ """
398
+ if not self.spec_data:
399
+ return {}
400
+
401
+ info = self.spec_data.get("info", {})
402
+
403
+ return {
404
+ "title": info.get("title", "Unknown API"),
405
+ "version": info.get("version", "Unknown"),
406
+ "description": info.get("description", ""),
407
+ "base_url": self._extract_base_url(),
408
+ "security_schemes": self._extract_security_schemes(),
409
+ }
410
+
411
+ def _extract_base_url(self) -> str:
412
+ """Extract base URL from servers section"""
413
+ if not isinstance(self.spec_data, dict):
414
+ return localhost_url
415
+
416
+ servers = self.spec_data.get("servers", [])
417
+ if servers and isinstance(servers[0], dict):
418
+ url = servers[0].get("url", localhost_url)
419
+ if isinstance(url, str):
420
+ return url
421
+ return localhost_url
422
+
423
+ def _extract_security_schemes(self) -> Dict[str, Any]:
424
+ """Extract security schemes from components"""
425
+ if not isinstance(self.components, dict):
426
+ return {}
427
+
428
+ schemes = self.components.get("securitySchemes", {})
429
+ if isinstance(schemes, dict):
430
+ return schemes
431
+ return {}
@@ -0,0 +1,94 @@
1
+ import httpx
2
+ import os
3
+ import re
4
+ import uuid
5
+ from typing import Optional
6
+ from devdox_ai_locust.schemas.processing_result import SwaggerProcessingRequest
7
+ import logging
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ async def get_api_schema(source: SwaggerProcessingRequest) -> Optional[str]:
13
+ """
14
+ Get API schema content from URL or file path based on source dictionary.
15
+
16
+ Args:
17
+ source (dict): Dictionary containing swagger_source ("url" or "file") and swagger_url/swagger_path
18
+
19
+
20
+ Expected source structure:
21
+ {
22
+ "swagger_source": "url", # or "file"
23
+ "swagger_url": "https://api.example.com/swagger.json", # if source is "url"
24
+ "swagger_path": "/path/to/swagger.json" # if source is "file"
25
+ }
26
+
27
+ Returns:
28
+ Optional[str]: Schema content as string, or None if failed
29
+
30
+ Raises:
31
+ ValueError: If source is invalid or missing required fields
32
+ FileNotFoundError: If file path doesn't exist
33
+ httpx.HTTPError: If URL request fails
34
+ Exception: For other unexpected errors
35
+ """
36
+
37
+ try:
38
+ if not source.swagger_url:
39
+ raise ValueError("Missing or empty 'swagger_url'")
40
+ swagger_url = source.swagger_url.strip()
41
+ if not swagger_url:
42
+ raise ValueError("Missing 'swagger_url' for url source")
43
+ return await _fetch_from_url(swagger_url)
44
+
45
+ except Exception as e:
46
+ source_info = getattr(source, "swagger_url", "unknown")
47
+
48
+ logger.error(f"Failed to get API schema from source '{source_info}': {str(e)}")
49
+ raise
50
+
51
+
52
+ async def _fetch_from_url(url: str) -> str:
53
+ """Fetch schema content from URL."""
54
+ headers = {
55
+ "User-Agent": "API-Schema-Fetcher/1.0",
56
+ "Accept": "application/json, application/yaml, text/yaml, text/plain, */*",
57
+ }
58
+
59
+ async with httpx.AsyncClient(timeout=30) as client:
60
+ try:
61
+ response = await client.get(url, headers=headers)
62
+ response.raise_for_status()
63
+
64
+ # Check content type
65
+ content_type = response.headers.get("content-type", "").lower()
66
+ logger.info(
67
+ f"Fetching schema from URL: {url}, Content-Type: {content_type}"
68
+ )
69
+
70
+ # Read content as text
71
+ content = response.text
72
+
73
+ if not content or not content.strip():
74
+ raise ValueError(f"Empty response from URL: {url}")
75
+
76
+ return content.strip()
77
+
78
+ except httpx.HTTPStatusError as e:
79
+ raise httpx.HTTPError(
80
+ f"HTTP {e.response.status_code}: {e.response.reason_phrase} for URL: {url}"
81
+ )
82
+ except httpx.TimeoutException:
83
+ raise httpx.HTTPError(f"Request timeout after 30s for URL: {url}")
84
+ except httpx.RequestError as e:
85
+ raise httpx.HTTPError(f"Request failed for URL {url}: {str(e)}")
86
+
87
+
88
+ def _sanitize_filename(filename: str) -> str:
89
+ # Remove directory components and sanitize
90
+ clean_name = os.path.basename(filename)
91
+ clean_name = re.sub(r"[^\w\-\.]", "", clean_name)
92
+ if not clean_name or clean_name.startswith("."):
93
+ clean_name = f"generated_{uuid.uuid4().hex[:8]}.py"
94
+ return clean_name