qase-python-commons 3.1.9__py3-none-any.whl → 4.1.9__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.
Files changed (35) hide show
  1. qase/__init__.py +3 -0
  2. qase/commons/client/api_v1_client.py +269 -175
  3. qase/commons/client/api_v2_client.py +163 -26
  4. qase/commons/client/base_api_client.py +23 -6
  5. qase/commons/config.py +162 -23
  6. qase/commons/logger.py +82 -13
  7. qase/commons/models/__init__.py +0 -2
  8. qase/commons/models/attachment.py +11 -8
  9. qase/commons/models/basemodel.py +12 -3
  10. qase/commons/models/config/framework.py +17 -0
  11. qase/commons/models/config/qaseconfig.py +34 -0
  12. qase/commons/models/config/run.py +19 -0
  13. qase/commons/models/config/testops.py +45 -3
  14. qase/commons/models/external_link.py +41 -0
  15. qase/commons/models/relation.py +16 -6
  16. qase/commons/models/result.py +16 -31
  17. qase/commons/models/run.py +17 -2
  18. qase/commons/models/runtime.py +9 -0
  19. qase/commons/models/step.py +45 -12
  20. qase/commons/profilers/__init__.py +4 -3
  21. qase/commons/profilers/db.py +965 -5
  22. qase/commons/reporters/core.py +60 -10
  23. qase/commons/reporters/report.py +11 -6
  24. qase/commons/reporters/testops.py +56 -27
  25. qase/commons/status_mapping/__init__.py +12 -0
  26. qase/commons/status_mapping/status_mapping.py +237 -0
  27. qase/commons/util/__init__.py +9 -0
  28. qase/commons/util/host_data.py +147 -0
  29. qase/commons/utils.py +95 -0
  30. {qase_python_commons-3.1.9.dist-info → qase_python_commons-4.1.9.dist-info}/METADATA +16 -11
  31. qase_python_commons-4.1.9.dist-info/RECORD +45 -0
  32. {qase_python_commons-3.1.9.dist-info → qase_python_commons-4.1.9.dist-info}/WHEEL +1 -1
  33. qase/commons/models/suite.py +0 -13
  34. qase_python_commons-3.1.9.dist-info/RECORD +0 -40
  35. {qase_python_commons-3.1.9.dist-info → qase_python_commons-4.1.9.dist-info}/top_level.txt +0 -0
qase/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """
2
+ Qase Python Commons package.
3
+ """
@@ -1,16 +1,18 @@
1
- from typing import Dict, Union
1
+ from datetime import datetime, timezone
2
+ from typing import Union, List
2
3
 
3
4
  import certifi
4
5
  from qase.api_client_v1 import ApiClient, ProjectsApi, Project, EnvironmentsApi, RunsApi, AttachmentsApi, \
5
- AttachmentGet, RunCreate, ResultsApi, ResultcreateBulk, AuthorsApi
6
+ AttachmentGet, RunCreate, ConfigurationsApi, ConfigurationCreate, ConfigurationGroupCreate, RunPublic
7
+ from qase.api_client_v1.models.attachmentupload import Attachmentupload
6
8
  from qase.api_client_v1.configuration import Configuration
7
9
  from .. import Logger
8
10
  from .base_api_client import BaseApiClient
9
11
  from ..exceptions.reporter import ReporterException
10
- from ..models import Attachment, Result, Step
12
+ from ..models import Attachment
11
13
  from ..models.config.framework import Video, Trace
12
14
  from ..models.config.qaseconfig import QaseConfig
13
- from ..models.step import StepType
15
+ from ..models.config.testops import ConfigurationValue
14
16
 
15
17
 
16
18
  class ApiV1Client(BaseApiClient):
@@ -66,7 +68,74 @@ class ApiV1Client(BaseApiClient):
66
68
  self.logger.log("Exception when calling EnvironmentsApi->get_environments: %s\n" % e, "error")
67
69
  raise ReporterException(e)
68
70
 
69
- def complete_run(self, project_code: str, run_id: str) -> None:
71
+ def get_configurations(self, project_code: str):
72
+ """Get all configurations for the project"""
73
+ try:
74
+ self.logger.log_debug(f"Getting configurations for project {project_code}")
75
+ api_instance = ConfigurationsApi(self.client)
76
+ response = api_instance.get_configurations(code=project_code)
77
+ if hasattr(response, 'result') and hasattr(response.result, 'entities'):
78
+ return response.result.entities
79
+ return []
80
+ except Exception as e:
81
+ self.logger.log(f"Exception when calling ConfigurationsApi->get_configurations: {e}", "error")
82
+ return []
83
+
84
+ def find_or_create_configuration(self, project_code: str, config_value: ConfigurationValue) -> Union[int, None]:
85
+ """Find existing configuration or create new one if createIfNotExists is True"""
86
+ try:
87
+ configurations = self.get_configurations(project_code)
88
+
89
+ # Search for existing configuration
90
+ for group in configurations:
91
+ if hasattr(group, 'configurations'):
92
+ for config in group.configurations:
93
+ # API returns configurations with 'title' field, not 'name' and 'value'
94
+ # We need to match group.title with config_value.name and config.title with config_value.value
95
+ config_title = config.title if hasattr(config, 'title') else 'No title'
96
+ group_title = group.title if hasattr(group, 'title') else 'No title'
97
+
98
+ if (group_title == config_value.name and config_title == config_value.value):
99
+ return config.id
100
+
101
+ # Configuration not found
102
+ if not self.config.testops.configurations.create_if_not_exists:
103
+ return None
104
+
105
+ # Create new configuration
106
+ # First, try to find existing group or create new one
107
+ group_id = None
108
+ for group in configurations:
109
+ if hasattr(group, 'title') and group.title == config_value.name:
110
+ group_id = group.id
111
+ break
112
+
113
+ if group_id is None:
114
+ # Create new group
115
+ group_create = ConfigurationGroupCreate(title=config_value.name)
116
+ group_response = ConfigurationsApi(self.client).create_configuration_group(
117
+ code=project_code,
118
+ configuration_group_create=group_create
119
+ )
120
+ group_id = group_response.result.id
121
+
122
+ # Create configuration in the group
123
+ config_create = ConfigurationCreate(
124
+ title=config_value.value,
125
+ group_id=group_id
126
+ )
127
+ config_response = ConfigurationsApi(self.client).create_configuration(
128
+ code=project_code,
129
+ configuration_create=config_create
130
+ )
131
+ config_id = config_response.result.id
132
+ return config_id
133
+
134
+ except Exception as e:
135
+ self.logger.log(f"Error at finding/creating configuration {config_value.name}={config_value.value}: {e}", "error")
136
+ return None
137
+
138
+ def complete_run(self, project_code: str, run_id: int) -> None:
70
139
  api_runs = RunsApi(self.client)
71
140
  self.logger.log_debug(f"Completing run {run_id}")
72
141
  res = api_runs.get_run(project_code, run_id).result
@@ -80,35 +149,158 @@ class ApiV1Client(BaseApiClient):
80
149
  self.logger.log(f"Error at completing run {run_id}: {e}", "error")
81
150
  raise ReporterException(e)
82
151
 
83
- def _upload_attachment(self, project_code: str, attachment: Attachment) -> Union[AttachmentGet, None]:
84
- try:
85
- self.logger.log_debug(f"Uploading attachment {attachment.id} for project {project_code}")
86
- attach_api = AttachmentsApi(self.client)
87
- response = attach_api.upload_attachment(project_code, file=[attachment.get_for_upload()])
88
-
89
- return response.result
90
-
91
- except Exception as e:
92
- self.logger.log(f"Error at uploading attachment: {e}", "debug")
93
- return None
152
+ def _upload_attachment(self, project_code: str, attachment: Union[Attachment, List[Attachment]]) -> List[Attachmentupload]:
153
+ """
154
+ Upload one or multiple attachments to Qase TestOps with batching support.
155
+
156
+ The method automatically groups attachments into batches respecting the following limits:
157
+ - Up to 32 MB per file
158
+ - Up to 128 MB per single request
159
+ - Up to 20 files per single request
160
+
161
+ :param project_code: project code
162
+ :param attachment: single attachment or list of attachments
163
+ :return: list of uploaded attachment data
164
+ """
165
+ # Normalize input to list
166
+ attachments = attachment if isinstance(attachment, list) else [attachment]
167
+
168
+ if not attachments:
169
+ return []
170
+
171
+ # Constants for upload limits
172
+ MAX_FILE_SIZE = 32 * 1024 * 1024 # 32 MB in bytes
173
+ MAX_REQUEST_SIZE = 128 * 1024 * 1024 # 128 MB in bytes
174
+ MAX_FILES_PER_REQUEST = 20
175
+
176
+ # Prepare attachments with size information
177
+ attachments_with_size = []
178
+ for att in attachments:
179
+ try:
180
+ # Get file data to check size
181
+ file_tuple = att.get_for_upload()
182
+ file_data = file_tuple[1] # Get file data (second element of tuple)
183
+ file_size = len(file_data)
184
+
185
+ # Check individual file size limit
186
+ if file_size > MAX_FILE_SIZE:
187
+ self.logger.log(
188
+ f"Attachment {att.file_name} ({file_size / 1024 / 1024:.2f} MB) exceeds "
189
+ f"maximum file size limit of 32 MB. Skipping.",
190
+ "error"
191
+ )
192
+ continue
193
+
194
+ attachments_with_size.append((att, file_size))
195
+ except Exception as e:
196
+ self.logger.log(f"Error preparing attachment {att.file_name}: {e}", "error")
197
+ continue
198
+
199
+ if not attachments_with_size:
200
+ return []
201
+
202
+ # Group attachments into batches
203
+ batches = []
204
+ current_batch = []
205
+ current_batch_size = 0
206
+
207
+ for att, file_size in attachments_with_size:
208
+ # Check if adding this file would exceed limits
209
+ would_exceed_size = current_batch_size + file_size > MAX_REQUEST_SIZE
210
+ would_exceed_count = len(current_batch) >= MAX_FILES_PER_REQUEST
211
+
212
+ if would_exceed_size or would_exceed_count:
213
+ # Start a new batch
214
+ if current_batch:
215
+ batches.append(current_batch)
216
+ current_batch = [att]
217
+ current_batch_size = file_size
218
+ else:
219
+ # Add to current batch
220
+ current_batch.append(att)
221
+ current_batch_size += file_size
222
+
223
+ # Add the last batch if it has items
224
+ if current_batch:
225
+ batches.append(current_batch)
226
+
227
+ # Upload batches
228
+ all_uploaded = []
229
+ attach_api = AttachmentsApi(self.client)
230
+
231
+ for batch_idx, batch in enumerate(batches, 1):
232
+ try:
233
+ self.logger.log_debug(
234
+ f"Uploading batch {batch_idx}/{len(batches)} with {len(batch)} file(s) "
235
+ f"for project {project_code}"
236
+ )
237
+
238
+ # Prepare files for upload
239
+ files_for_upload = [att.get_for_upload() for att in batch]
240
+
241
+ # Upload batch
242
+ response = attach_api.upload_attachment(project_code, file=files_for_upload)
243
+
244
+ if response.result:
245
+ all_uploaded.extend(response.result)
246
+ self.logger.log_debug(
247
+ f"Successfully uploaded batch {batch_idx}/{len(batches)}: "
248
+ f"{len(response.result)} file(s)"
249
+ )
250
+ else:
251
+ self.logger.log(
252
+ f"Batch {batch_idx}/{len(batches)} upload returned no results",
253
+ "error"
254
+ )
255
+
256
+ except Exception as e:
257
+ self.logger.log(
258
+ f"Error uploading batch {batch_idx}/{len(batches)}: {e}",
259
+ "error"
260
+ )
261
+ # Continue with next batch even if one fails
262
+ continue
263
+
264
+ return all_uploaded
94
265
 
95
266
  def create_test_run(self, project_code: str, title: str, description: str, plan_id=None,
96
267
  environment_id=None) -> str:
268
+ # Process configurations
269
+ configuration_ids = []
270
+
271
+ if self.config.testops.configurations and self.config.testops.configurations.values:
272
+ for config_value in self.config.testops.configurations.values:
273
+ config_id = self.find_or_create_configuration(project_code, config_value)
274
+ if config_id:
275
+ configuration_ids.append(config_id)
276
+
97
277
  kwargs = dict(
98
278
  title=title,
99
279
  description=description,
100
280
  environment_id=(int(environment_id) if environment_id else None),
101
281
  plan_id=(int(plan_id) if plan_id else plan_id),
102
- is_autotest=True
282
+ is_autotest=True,
283
+ start_time=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
284
+ tags=self.config.testops.run.tags
103
285
  )
104
- self.logger.log_debug(f"Creating test run with parameters: {kwargs}")
286
+
287
+ # Add configurations if any found
288
+ if configuration_ids:
289
+ kwargs['configurations'] = configuration_ids
290
+
105
291
  try:
106
292
  result = RunsApi(self.client).create_run(
107
293
  code=project_code,
108
294
  run_create=RunCreate(**{k: v for k, v in kwargs.items() if v is not None})
109
295
  )
110
296
 
111
- return result.result.id
297
+ run_id = result.result.id
298
+
299
+ # Update external link if configured
300
+ if self.config.testops.run.external_link and run_id:
301
+ self.update_external_link(project_code, run_id)
302
+
303
+ return run_id
112
304
 
113
305
  except Exception as e:
114
306
  self.logger.log(f"Error at creating test run: {e}", "error")
@@ -121,166 +313,65 @@ class ApiV1Client(BaseApiClient):
121
313
  return True
122
314
  return False
123
315
 
124
- def send_results(self, project_code: str, run_id: str, results: []) -> None:
125
- api_results = ResultsApi(self.client)
126
- results_to_send = [self._prepare_result(project_code, result) for result in results]
127
- self.logger.log_debug(f"Sending results for run {run_id}: {results_to_send}")
128
- api_results.create_result_bulk(
129
- code=project_code,
130
- id=run_id,
131
- resultcreate_bulk=ResultcreateBulk(
132
- results=results_to_send
133
- )
134
- )
135
- self.logger.log_debug(f"Results for run {run_id} sent successfully")
136
-
137
- def _prepare_result(self, project_code: str, result: Result) -> Dict:
138
- attached = []
139
- if result.attachments:
140
- for attachment in result.attachments:
141
- if self.__should_skip_attachment(attachment, result):
142
- continue
143
- attach_id = self._upload_attachment(project_code, attachment)
144
- if attach_id:
145
- attached.extend(attach_id)
146
-
147
- steps = []
148
- for step in result.steps:
149
- prepared = self._prepare_step(project_code, step)
150
- steps.append(prepared)
151
-
152
- case_data = {
153
- "title": result.get_title(),
154
- "description": result.get_field('description'),
155
- "preconditions": result.get_field('preconditions'),
156
- "postconditions": result.get_field('postconditions'),
157
- }
158
-
159
- for key, param in result.params.items():
160
- # Hack to match old TestOps API
161
- if param == "":
162
- result.params[key] = "empty"
163
-
164
- if result.get_field('severity'):
165
- case_data["severity"] = result.get_field('severity')
166
-
167
- if result.get_field('priority'):
168
- case_data["priority"] = result.get_field('priority')
169
-
170
- if result.get_field('layer'):
171
- case_data["layer"] = result.get_field('layer')
172
-
173
- suite = None
174
- if result.get_suite_title():
175
- suite = "\t".join(result.get_suite_title().split("."))
176
-
177
- if result.get_field('suite'):
178
- suite = result.get_field('suite')
179
-
180
- root_suite = self.config.root_suite
181
- if root_suite:
182
- suite = f"{root_suite}\t{suite}"
183
-
184
- if suite:
185
- case_data["suite_title"] = suite
186
-
187
- result_model = {
188
- "status": result.execution.status,
189
- "stacktrace": result.execution.stacktrace,
190
- "time_ms": result.execution.duration,
191
- "comment": result.message,
192
- "attachments": [attach.hash for attach in attached],
193
- "steps": steps,
194
- "param": result.params,
195
- "param_groups": result.param_groups,
196
- "defect": self.config.testops.defect,
197
- "case": case_data
198
- }
199
-
200
- test_ops_id = result.get_testops_id()
201
-
202
- if test_ops_id:
203
- result_model["case_id"] = test_ops_id
204
-
205
- if result.get_field('author'):
206
- author_id = self._get_author_id(result.get_field('author'))
207
- if author_id:
208
- result_model["author_id"] = author_id
209
-
210
- self.logger.log_debug(f"Prepared result: {result_model}")
211
-
212
- return result_model
213
-
214
- def _prepare_step(self, project_code: str, step: Step) -> Dict:
215
- prepared_children = []
216
-
316
+ def enable_public_report(self, project_code: str, run_id: int) -> str:
317
+ """
318
+ Enable public report for a test run and return the public link
319
+
320
+ :param project_code: project code
321
+ :param run_id: test run id
322
+ :return: public report link or None if failed
323
+ """
217
324
  try:
218
- prepared_step = {"time": step.execution.duration, "status": step.execution.status}
219
-
220
- if step.execution.status == 'untested':
221
- prepared_step["status"] = 'passed'
222
-
223
- if step.execution.status == 'skipped':
224
- prepared_step["status"] = 'blocked'
225
-
226
- if step.step_type == StepType.TEXT:
227
- prepared_step['action'] = step.data.action
228
- if step.data.expected_result:
229
- prepared_step['expected_result'] = step.data.expected_result
230
-
231
- if step.step_type == StepType.REQUEST:
232
- prepared_step['action'] = step.data.request_method + " " + step.data.request_url
233
- if step.data.request_body:
234
- step.attachments.append(
235
- Attachment(file_name='request_body.txt', content=step.data.request_body, mime_type='text/plain',
236
- temporary=True))
237
- if step.data.request_headers:
238
- step.attachments.append(
239
- Attachment(file_name='request_headers.txt', content=step.data.request_headers,
240
- mime_type='text/plain', temporary=True))
241
- if step.data.response_body:
242
- step.attachments.append(Attachment(file_name='response_body.txt', content=step.data.response_body,
243
- mime_type='text/plain', temporary=True))
244
- if step.data.response_headers:
245
- step.attachments.append(
246
- Attachment(file_name='response_headers.txt', content=step.data.response_headers,
247
- mime_type='text/plain', temporary=True))
248
-
249
- if step.step_type == StepType.GHERKIN:
250
- prepared_step['action'] = step.data.keyword
251
-
252
- if step.step_type == StepType.SLEEP:
253
- prepared_step['action'] = f"Sleep for {step.data.duration} seconds"
254
-
255
- if step.attachments:
256
- uploaded_attachments = []
257
- for file in step.attachments:
258
- attach_id = self._upload_attachment(project_code, file)
259
- if attach_id:
260
- uploaded_attachments.extend(attach_id)
261
- prepared_step['attachments'] = [attach.hash for attach in uploaded_attachments]
262
-
263
- if step.steps:
264
- for substep in step.steps:
265
- prepared_children.append(self._prepare_step(project_code, substep))
266
- prepared_step["steps"] = prepared_children
267
- return prepared_step
325
+ self.logger.log_debug(f"Enabling public report for run {run_id}")
326
+ api_runs = RunsApi(self.client)
327
+
328
+ # Create RunPublic object with status=True
329
+ run_public = RunPublic(status=True)
330
+
331
+ # Call the API to enable public report
332
+ response = api_runs.update_run_publicity(project_code, run_id, run_public)
333
+
334
+ # Extract the public URL from response
335
+ if response.result and response.result.url:
336
+ public_url = response.result.url
337
+ self.logger.log_debug(f"Public report enabled for run {run_id}: {public_url}")
338
+ return public_url
339
+ else:
340
+ self.logger.log_debug(f"Public report enabled for run {run_id} but no URL returned")
341
+ return None
342
+
268
343
  except Exception as e:
269
- self.logger.log(f"Error at preparing step: {e}", "error")
270
- raise ReporterException(e)
271
-
272
- def _get_author_id(self, author: str) -> Union[str, None]:
273
- if author in self.__authors:
274
- return self.__authors[author]
275
-
276
- author_api = AuthorsApi(self.client)
277
- authors = author_api.get_authors(search=author)
278
- if authors.result.total == 0:
344
+ self.logger.log(f"Error at enabling public report for run {run_id}: {e}", "error")
279
345
  return None
280
346
 
281
- self.__authors[author] = authors.result.entities[0].author_id
282
-
283
- return authors.result.entities[0].author_id
347
+ def update_external_link(self, project_code: str, run_id: int):
348
+ """Update external link for a test run"""
349
+ try:
350
+ from qase.api_client_v1.models.runexternal_issues import RunexternalIssues
351
+ from qase.api_client_v1.models.runexternal_issues_links_inner import RunexternalIssuesLinksInner
352
+
353
+ external_link = self.config.testops.run.external_link
354
+ api_type = external_link.to_api_type()
355
+
356
+ run_external_issues = RunexternalIssues(
357
+ type=api_type,
358
+ links=[
359
+ RunexternalIssuesLinksInner(
360
+ run_id=run_id,
361
+ external_issue=external_link.link
362
+ )
363
+ ]
364
+ )
365
+
366
+ RunsApi(self.client).run_update_external_issue(
367
+ code=project_code,
368
+ runexternal_issues=run_external_issues
369
+ )
370
+
371
+ self.logger.log(f"External link updated for run {run_id}: {external_link.link}", "debug")
372
+
373
+ except Exception as e:
374
+ self.logger.log(f"Error at updating external link: {e}", "error")
284
375
 
285
376
  def __should_skip_attachment(self, attachment, result):
286
377
  if (self.config.framework.playwright.video == Video.failed and
@@ -292,3 +383,6 @@ class ApiV1Client(BaseApiClient):
292
383
  attachment.file_name == 'trace.zip'):
293
384
  return True
294
385
  return False
386
+
387
+ def send_results(self, project_code: str, run_id: str, results: []) -> None:
388
+ raise NotImplementedError("use ApiV2Client instead")