qastudio-pytest 1.0.4__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.
@@ -0,0 +1,13 @@
1
+ """
2
+ QAStudio pytest plugin for test management integration.
3
+
4
+ This plugin automatically reports pytest test results to QAStudio.dev platform.
5
+ """
6
+
7
+ __version__ = "1.0.4"
8
+ __author__ = "QAStudio"
9
+ __email__ = "support@qastudio.dev"
10
+
11
+ from .plugin import QAStudioPlugin
12
+
13
+ __all__ = ["QAStudioPlugin"]
@@ -0,0 +1,379 @@
1
+ """API client for QAStudio.dev integration."""
2
+
3
+ from typing import Any, Dict, List, Optional
4
+ import requests
5
+ from requests.adapters import HTTPAdapter
6
+ from urllib3.util.retry import Retry
7
+
8
+ from .models import ReporterConfig, TestResult, TestRunSummary
9
+ from .utils import sanitize_string
10
+
11
+
12
+ class APIError(Exception):
13
+ """Custom exception for API errors."""
14
+
15
+ def __init__(self, status_code: int, message: str):
16
+ self.status_code = status_code
17
+ super().__init__(f"API Error {status_code}: {message}")
18
+
19
+
20
+ class QAStudioAPIClient:
21
+ """Client for communicating with QAStudio.dev API."""
22
+
23
+ def __init__(self, config: ReporterConfig):
24
+ """Initialize API client with configuration."""
25
+ self.config = config
26
+ self.session = self._create_session()
27
+ self.base_url = sanitize_string(config.api_url) or ""
28
+ self.api_key = sanitize_string(config.api_key) or ""
29
+ self.project_id = sanitize_string(config.project_id) or ""
30
+
31
+ def _create_session(self) -> requests.Session:
32
+ """Create requests session with retry logic."""
33
+ session = requests.Session()
34
+
35
+ # Configure retry strategy
36
+ retry_strategy = Retry(
37
+ total=self.config.max_retries,
38
+ backoff_factor=1,
39
+ status_forcelist=[429, 500, 502, 503, 504],
40
+ allowed_methods=["HEAD", "GET", "POST", "PUT", "DELETE", "OPTIONS", "TRACE"],
41
+ )
42
+
43
+ adapter = HTTPAdapter(max_retries=retry_strategy)
44
+ session.mount("http://", adapter)
45
+ session.mount("https://", adapter)
46
+
47
+ return session
48
+
49
+ def _make_request(
50
+ self,
51
+ method: str,
52
+ path: str,
53
+ json_data: Optional[Dict[str, Any]] = None,
54
+ ) -> Dict[str, Any]:
55
+ """
56
+ Make HTTP request to API with error handling.
57
+
58
+ Args:
59
+ method: HTTP method
60
+ path: API endpoint path
61
+ json_data: JSON data to send
62
+
63
+ Returns:
64
+ Response JSON data
65
+
66
+ Raises:
67
+ APIError: If request fails
68
+ """
69
+ url = f"{self.base_url}{path}"
70
+ headers = {
71
+ "Authorization": f"Bearer {self.api_key}",
72
+ "Content-Type": "application/json",
73
+ "User-Agent": "qastudio-pytest/1.0.0",
74
+ }
75
+
76
+ try:
77
+ self._log(f"Making {method} request to {path}")
78
+
79
+ response = self.session.request(
80
+ method=method,
81
+ url=url,
82
+ json=json_data,
83
+ headers=headers,
84
+ timeout=self.config.timeout,
85
+ )
86
+
87
+ # Raise for 4xx/5xx status codes
88
+ if not response.ok:
89
+ error_msg = response.text or response.reason
90
+ raise APIError(response.status_code, error_msg)
91
+
92
+ # Return JSON if present
93
+ if response.content:
94
+ json_response: Dict[str, Any] = response.json()
95
+ return json_response
96
+ return {}
97
+
98
+ except requests.exceptions.Timeout as e:
99
+ raise APIError(408, f"Request timeout: {str(e)}")
100
+ except requests.exceptions.ConnectionError as e:
101
+ raise APIError(503, f"Connection error: {str(e)}")
102
+ except requests.exceptions.RequestException as e:
103
+ raise APIError(500, f"Request failed: {str(e)}")
104
+
105
+ def create_test_run(
106
+ self,
107
+ name: str,
108
+ description: Optional[str] = None,
109
+ ) -> Dict[str, Any]:
110
+ """
111
+ Create a new test run.
112
+
113
+ Args:
114
+ name: Test run name
115
+ description: Optional description
116
+
117
+ Returns:
118
+ Test run data with 'id' field
119
+ """
120
+ self._log(f"Creating test run: {name}")
121
+
122
+ data = {
123
+ "projectId": self.project_id,
124
+ "name": name,
125
+ "environment": self.config.environment,
126
+ }
127
+
128
+ if description:
129
+ data["description"] = description
130
+
131
+ response = self._make_request("POST", "/runs", json_data=data)
132
+
133
+ self._log(f"Created test run with ID: {response.get('id')}")
134
+ return response
135
+
136
+ def submit_test_results(
137
+ self,
138
+ test_run_id: str,
139
+ results: List[TestResult],
140
+ ) -> Dict[str, Any]:
141
+ """
142
+ Submit test results to a test run.
143
+
144
+ Args:
145
+ test_run_id: Test run ID
146
+ results: List of test results
147
+
148
+ Returns:
149
+ Response data
150
+ """
151
+ self._log(f"Submitting {len(results)} test results to run {test_run_id}")
152
+
153
+ data = {
154
+ "testRunId": test_run_id,
155
+ "results": [result.to_dict() for result in results],
156
+ }
157
+
158
+ response = self._make_request("POST", "/results", json_data=data)
159
+
160
+ self._log(f"Successfully submitted {len(results)} results")
161
+ return response
162
+
163
+ def complete_test_run(
164
+ self,
165
+ test_run_id: str,
166
+ summary: TestRunSummary,
167
+ ) -> Dict[str, Any]:
168
+ """
169
+ Mark test run as complete with summary.
170
+
171
+ Args:
172
+ test_run_id: Test run ID
173
+ summary: Test run summary
174
+
175
+ Returns:
176
+ Response data
177
+ """
178
+ self._log(f"Completing test run {test_run_id}")
179
+
180
+ data = {
181
+ "testRunId": test_run_id,
182
+ "summary": summary.to_dict(),
183
+ }
184
+
185
+ response = self._make_request("POST", f"/runs/{test_run_id}/complete", json_data=data)
186
+
187
+ self._log("Test run completed successfully")
188
+ return response
189
+
190
+ def upload_attachment(
191
+ self,
192
+ test_result_id: str,
193
+ file_path: str,
194
+ attachment_type: Optional[str] = None,
195
+ ) -> Dict[str, Any]:
196
+ """
197
+ Upload an attachment file to a test result.
198
+
199
+ Args:
200
+ test_result_id: Test result ID to attach file to
201
+ file_path: Path to file to upload
202
+ attachment_type: Optional type (e.g., 'screenshot', 'video', 'log')
203
+
204
+ Returns:
205
+ Response data with attachment info
206
+
207
+ Raises:
208
+ APIError: If upload fails
209
+ """
210
+ import os
211
+ import mimetypes
212
+
213
+ if not os.path.exists(file_path):
214
+ raise APIError(400, f"File not found: {file_path}")
215
+
216
+ filename = os.path.basename(file_path)
217
+ content_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
218
+
219
+ self._log(f"Uploading attachment: {filename} ({content_type})")
220
+
221
+ with open(file_path, "rb") as f:
222
+ file_data = f.read()
223
+
224
+ # Prepare multipart form data
225
+ fields = {
226
+ "testResultId": test_result_id,
227
+ }
228
+
229
+ if attachment_type:
230
+ fields["type"] = attachment_type
231
+
232
+ return self._upload_multipart("/attachments", fields, filename, content_type, file_data)
233
+
234
+ def _upload_multipart(
235
+ self,
236
+ path: str,
237
+ fields: Dict[str, str],
238
+ filename: str,
239
+ content_type: str,
240
+ file_data: bytes,
241
+ ) -> Dict[str, Any]:
242
+ """
243
+ Upload multipart/form-data request with retry logic.
244
+
245
+ Args:
246
+ path: API endpoint path
247
+ fields: Form fields
248
+ filename: Name of file being uploaded
249
+ content_type: MIME type of file
250
+ file_data: File content as bytes
251
+
252
+ Returns:
253
+ Response JSON data
254
+
255
+ Raises:
256
+ APIError: If request fails
257
+ """
258
+ url = f"{self.base_url}{path}"
259
+ last_error: Optional[Exception] = None
260
+
261
+ for attempt in range(self.config.max_retries):
262
+ try:
263
+ if attempt > 0:
264
+ self._log(f"Retry attempt {attempt + 1}/{self.config.max_retries}")
265
+ import time
266
+
267
+ time.sleep(min(1 * (2**attempt), 10)) # Exponential backoff
268
+
269
+ return self._make_multipart_request(url, fields, filename, content_type, file_data)
270
+
271
+ except APIError as e:
272
+ last_error = e
273
+ self._log(f"Upload failed (attempt {attempt + 1}/{self.config.max_retries}): {e}")
274
+
275
+ # Don't retry on 4xx errors (client errors)
276
+ if 400 <= e.status_code < 500:
277
+ raise
278
+
279
+ except Exception as e:
280
+ last_error = e
281
+ self._log(f"Upload failed (attempt {attempt + 1}/{self.config.max_retries}): {e}")
282
+
283
+ if last_error:
284
+ raise last_error
285
+ raise APIError(500, "Upload failed after all retries")
286
+
287
+ def _make_multipart_request(
288
+ self,
289
+ url: str,
290
+ fields: Dict[str, str],
291
+ filename: str,
292
+ content_type: str,
293
+ file_data: bytes,
294
+ ) -> Dict[str, Any]:
295
+ """
296
+ Make a multipart/form-data HTTP request.
297
+
298
+ Args:
299
+ url: Full URL to request
300
+ fields: Form fields (non-file)
301
+ filename: Name of file being uploaded
302
+ content_type: MIME type of file
303
+ file_data: File content as bytes
304
+
305
+ Returns:
306
+ Response JSON data
307
+
308
+ Raises:
309
+ APIError: If request fails
310
+ """
311
+ import uuid
312
+
313
+ # Generate boundary
314
+ boundary = f"----FormBoundary{uuid.uuid4().hex}"
315
+
316
+ # Build multipart body
317
+ body_parts = []
318
+
319
+ # Add form fields
320
+ for key, value in fields.items():
321
+ body_parts.append(f"--{boundary}\r\n".encode())
322
+ body_parts.append(f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode())
323
+ body_parts.append(f"{value}\r\n".encode())
324
+
325
+ # Add file field
326
+ body_parts.append(f"--{boundary}\r\n".encode())
327
+ body_parts.append(
328
+ f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode()
329
+ )
330
+ body_parts.append(f"Content-Type: {content_type}\r\n\r\n".encode())
331
+ body_parts.append(file_data)
332
+ body_parts.append(b"\r\n")
333
+
334
+ # Add closing boundary
335
+ body_parts.append(f"--{boundary}--\r\n".encode())
336
+
337
+ body = b"".join(body_parts)
338
+
339
+ headers = {
340
+ "Authorization": f"Bearer {self.api_key}",
341
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
342
+ "User-Agent": "qastudio-pytest/1.0.0",
343
+ }
344
+
345
+ try:
346
+ response = self.session.request(
347
+ method="POST",
348
+ url=url,
349
+ data=body,
350
+ headers=headers,
351
+ timeout=self.config.timeout,
352
+ )
353
+
354
+ # Raise for 4xx/5xx status codes
355
+ if not response.ok:
356
+ error_msg = response.text or response.reason
357
+ raise APIError(response.status_code, error_msg)
358
+
359
+ # Return JSON if present
360
+ if response.content:
361
+ json_response: Dict[str, Any] = response.json()
362
+ return json_response
363
+ return {}
364
+
365
+ except requests.exceptions.Timeout as e:
366
+ raise APIError(408, f"Request timeout: {str(e)}")
367
+ except requests.exceptions.ConnectionError as e:
368
+ raise APIError(503, f"Connection error: {str(e)}")
369
+ except requests.exceptions.RequestException as e:
370
+ raise APIError(500, f"Request failed: {str(e)}")
371
+
372
+ def _log(self, message: str) -> None:
373
+ """Log message if verbose mode is enabled."""
374
+ if self.config.verbose:
375
+ print(f"[QAStudio] {message}")
376
+
377
+ def close(self) -> None:
378
+ """Close the session."""
379
+ self.session.close()
@@ -0,0 +1,259 @@
1
+ """Data models for QAStudio.dev API integration."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import Any, Dict, List, Optional
6
+ from enum import Enum
7
+
8
+
9
+ class TestStatus(Enum):
10
+ """Test execution status."""
11
+
12
+ PASSED = "passed"
13
+ FAILED = "failed"
14
+ SKIPPED = "skipped"
15
+ ERROR = "error"
16
+
17
+
18
+ @dataclass
19
+ class TestResult:
20
+ """Test result data to send to QAStudio.dev."""
21
+
22
+ test_case_id: Optional[str]
23
+ title: str
24
+ full_title: str
25
+ status: TestStatus
26
+ duration: float # in seconds
27
+ error: Optional[str] = None
28
+ stack_trace: Optional[str] = None
29
+ error_snippet: Optional[str] = None
30
+ error_location: Optional[Dict[str, Any]] = None
31
+ steps: Optional[List[Dict[str, Any]]] = None
32
+ console_output: Optional[Dict[str, str]] = None
33
+ start_time: Optional[str] = None
34
+ end_time: Optional[str] = None
35
+ file_path: Optional[str] = None
36
+ line_number: Optional[int] = None
37
+ attachments: Optional[List[Dict[str, Any]]] = None
38
+ attachment_paths: List[str] = field(default_factory=list)
39
+ metadata: Dict[str, Any] = field(default_factory=dict)
40
+ result_id: Optional[str] = None # API result ID for uploading attachments
41
+
42
+ def to_dict(self) -> Dict[str, Any]:
43
+ """Convert to dictionary for API submission."""
44
+ result: Dict[str, Any] = {
45
+ "title": self.title,
46
+ "status": self.status.value,
47
+ "duration": int(self.duration * 1000), # Convert to milliseconds
48
+ }
49
+
50
+ # Add optional fields that the API expects
51
+ if self.full_title:
52
+ result["fullTitle"] = self.full_title
53
+
54
+ if self.error:
55
+ result["errorMessage"] = self.error
56
+
57
+ if self.stack_trace:
58
+ result["stackTrace"] = self.stack_trace
59
+
60
+ # Add attachments array if present
61
+ if self.attachments:
62
+ result["attachments"] = self.attachments
63
+
64
+ return result
65
+
66
+ @classmethod
67
+ def from_pytest_report(
68
+ cls, item: Any, report: Any, config: Optional["ReporterConfig"] = None
69
+ ) -> "TestResult":
70
+ """Create TestResult from pytest item and report."""
71
+ from .utils import (
72
+ extract_test_case_id,
73
+ get_full_test_name,
74
+ extract_error_snippet,
75
+ extract_error_location,
76
+ extract_console_output,
77
+ extract_test_steps,
78
+ )
79
+
80
+ # Extract test case ID from markers or test name
81
+ test_case_id = extract_test_case_id(item)
82
+
83
+ # Determine status
84
+ if report.passed:
85
+ status = TestStatus.PASSED
86
+ elif report.failed:
87
+ status = TestStatus.FAILED if report.when == "call" else TestStatus.ERROR
88
+ elif report.skipped:
89
+ status = TestStatus.SKIPPED
90
+ else:
91
+ status = TestStatus.ERROR
92
+
93
+ # Extract error information
94
+ error = None
95
+ stack_trace = None
96
+ error_snippet = None
97
+ error_location = None
98
+ if report.longrepr:
99
+ if hasattr(report.longrepr, "reprcrash"):
100
+ error = str(report.longrepr.reprcrash.message)
101
+ if hasattr(report.longrepr, "reprtraceback"):
102
+ stack_trace = str(report.longrepr.reprtraceback)
103
+ elif isinstance(report.longrepr, tuple):
104
+ # For skipped tests
105
+ error = str(report.longrepr[2])
106
+ else:
107
+ error = str(report.longrepr)
108
+
109
+ # Extract error snippet and location if enabled
110
+ if config:
111
+ if config.include_error_snippet:
112
+ error_snippet = extract_error_snippet(report)
113
+ if config.include_error_location:
114
+ error_location = extract_error_location(report)
115
+
116
+ # Extract metadata from markers
117
+ metadata = {}
118
+ for marker in item.iter_markers():
119
+ if marker.name.startswith("qastudio_"):
120
+ key = marker.name.replace("qastudio_", "")
121
+ if key != "id": # Already extracted as test_case_id
122
+ metadata[key] = marker.args[0] if marker.args else True
123
+
124
+ # Get location information
125
+ file_path = str(item.fspath) if hasattr(item, "fspath") else None
126
+ line_number = item.location[1] if hasattr(item, "location") else None
127
+
128
+ # Extract console output if enabled
129
+ console_output = None
130
+ if config and config.include_console_output:
131
+ console_output = extract_console_output(report)
132
+
133
+ # Extract test steps if enabled
134
+ steps = None
135
+ if config and config.include_test_steps:
136
+ steps = extract_test_steps(report)
137
+
138
+ return cls(
139
+ test_case_id=test_case_id,
140
+ title=item.name,
141
+ full_title=get_full_test_name(item),
142
+ status=status,
143
+ duration=report.duration,
144
+ error=error,
145
+ stack_trace=stack_trace,
146
+ error_snippet=error_snippet,
147
+ error_location=error_location,
148
+ steps=steps,
149
+ console_output=console_output,
150
+ start_time=datetime.now().isoformat(),
151
+ end_time=datetime.now().isoformat(),
152
+ file_path=file_path,
153
+ line_number=line_number,
154
+ metadata=metadata,
155
+ )
156
+
157
+
158
+ @dataclass
159
+ class TestRunSummary:
160
+ """Summary of test run results."""
161
+
162
+ total: int
163
+ passed: int
164
+ failed: int
165
+ skipped: int
166
+ errors: int
167
+ duration: float # in seconds
168
+
169
+ def to_dict(self) -> Dict[str, Any]:
170
+ """Convert to dictionary for API submission."""
171
+ return {
172
+ "total": self.total,
173
+ "passed": self.passed,
174
+ "failed": self.failed,
175
+ "skipped": self.skipped + self.errors,
176
+ "duration": int(self.duration * 1000), # Convert to milliseconds
177
+ }
178
+
179
+
180
+ @dataclass
181
+ class ReporterConfig:
182
+ """Configuration for QAStudio reporter."""
183
+
184
+ api_url: str
185
+ api_key: str
186
+ project_id: str
187
+ environment: str = "default"
188
+ test_run_id: Optional[str] = None
189
+ test_run_name: Optional[str] = None
190
+ test_run_description: Optional[str] = None
191
+ create_test_run: bool = True
192
+ batch_size: int = 10
193
+ silent: bool = True
194
+ verbose: bool = False
195
+ max_retries: int = 3
196
+ timeout: int = 30
197
+ include_error_snippet: bool = True
198
+ include_error_location: bool = True
199
+ include_test_steps: bool = True
200
+ include_console_output: bool = False
201
+ upload_attachments: bool = True
202
+ attachments_dir: Optional[str] = None
203
+
204
+ @classmethod
205
+ def from_pytest_config(cls, config: Any) -> "ReporterConfig":
206
+ """Create ReporterConfig from pytest config."""
207
+ import os
208
+
209
+ def get_option(name: str, default: Any = None) -> Any:
210
+ """Get option from pytest config, environment, or default."""
211
+ # Try pytest config first
212
+ value = config.getini(name)
213
+ if value:
214
+ return value
215
+
216
+ # Try command line option
217
+ cli_name = f"--{name.replace('_', '-')}"
218
+ value = config.getoption(cli_name, default=None)
219
+ if value is not None:
220
+ return value
221
+
222
+ # Try environment variable
223
+ env_name = name.upper()
224
+ value = os.environ.get(env_name)
225
+ if value is not None:
226
+ # Convert string booleans
227
+ if value.lower() in ("true", "1", "yes"):
228
+ return True
229
+ elif value.lower() in ("false", "0", "no"):
230
+ return False
231
+ # Convert string numbers
232
+ try:
233
+ return int(value)
234
+ except ValueError:
235
+ return value
236
+
237
+ return default
238
+
239
+ return cls(
240
+ api_url=get_option("qastudio_api_url", "https://qastudio.dev/api"),
241
+ api_key=get_option("qastudio_api_key"),
242
+ project_id=get_option("qastudio_project_id"),
243
+ environment=get_option("qastudio_environment", "default"),
244
+ test_run_id=get_option("qastudio_test_run_id"),
245
+ test_run_name=get_option("qastudio_test_run_name"),
246
+ test_run_description=get_option("qastudio_test_run_description"),
247
+ create_test_run=get_option("qastudio_create_test_run", True),
248
+ batch_size=get_option("qastudio_batch_size", 10),
249
+ silent=get_option("qastudio_silent", True),
250
+ verbose=get_option("qastudio_verbose", False),
251
+ max_retries=get_option("qastudio_max_retries", 3),
252
+ timeout=get_option("qastudio_timeout", 30),
253
+ include_error_snippet=get_option("qastudio_include_error_snippet", True),
254
+ include_error_location=get_option("qastudio_include_error_location", True),
255
+ include_test_steps=get_option("qastudio_include_test_steps", True),
256
+ include_console_output=get_option("qastudio_include_console_output", False),
257
+ upload_attachments=get_option("qastudio_upload_attachments", True),
258
+ attachments_dir=get_option("qastudio_attachments_dir"),
259
+ )