qase-python-commons 3.1.3__py3-none-any.whl → 4.1.3__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 qase-python-commons might be problematic. Click here for more details.

Files changed (36) hide show
  1. qase/__init__.py +3 -0
  2. qase/commons/client/api_v1_client.py +169 -143
  3. qase/commons/client/api_v2_client.py +77 -23
  4. qase/commons/client/base_api_client.py +12 -1
  5. qase/commons/config.py +159 -20
  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 +61 -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 +15 -1
  19. qase/commons/models/step.py +43 -11
  20. qase/commons/profilers/__init__.py +4 -3
  21. qase/commons/profilers/db.py +965 -5
  22. qase/commons/profilers/network.py +5 -1
  23. qase/commons/reporters/core.py +50 -9
  24. qase/commons/reporters/report.py +11 -6
  25. qase/commons/reporters/testops.py +56 -22
  26. qase/commons/status_mapping/__init__.py +12 -0
  27. qase/commons/status_mapping/status_mapping.py +237 -0
  28. qase/commons/util/__init__.py +9 -0
  29. qase/commons/util/host_data.py +140 -0
  30. qase/commons/utils.py +95 -0
  31. {qase_python_commons-3.1.3.dist-info → qase_python_commons-4.1.3.dist-info}/METADATA +16 -11
  32. qase_python_commons-4.1.3.dist-info/RECORD +45 -0
  33. {qase_python_commons-3.1.3.dist-info → qase_python_commons-4.1.3.dist-info}/WHEEL +1 -1
  34. qase/commons/models/suite.py +0 -13
  35. qase_python_commons-3.1.3.dist-info/RECORD +0 -40
  36. {qase_python_commons-3.1.3.dist-info → qase_python_commons-4.1.3.dist-info}/top_level.txt +0 -0
@@ -1,4 +1,5 @@
1
1
  import sys
2
+ import threading
2
3
  import uuid
3
4
  from functools import wraps
4
5
  from ..models.runtime import Runtime
@@ -108,11 +109,14 @@ class NetworkProfiler:
108
109
 
109
110
  class NetworkProfilerSingleton:
110
111
  _instance = None
112
+ _lock = threading.Lock()
111
113
 
112
114
  @staticmethod
113
115
  def init(**kwargs):
114
116
  if NetworkProfilerSingleton._instance is None:
115
- NetworkProfilerSingleton._instance = NetworkProfiler(**kwargs)
117
+ with NetworkProfilerSingleton._lock:
118
+ if NetworkProfilerSingleton._instance is None:
119
+ NetworkProfilerSingleton._instance = NetworkProfiler(**kwargs)
116
120
 
117
121
  @staticmethod
118
122
  def get_instance() -> NetworkProfiler:
@@ -1,5 +1,3 @@
1
- import os
2
- import json
3
1
  import time
4
2
 
5
3
  from ..config import ConfigManager
@@ -12,6 +10,9 @@ from ..models import Result, Attachment, Runtime
12
10
  from ..models.config.qaseconfig import Mode
13
11
  from typing import Union, List
14
12
 
13
+ from ..util import get_host_info
14
+ from ..status_mapping.status_mapping import StatusMapping
15
+
15
16
  """
16
17
  CoreReporter is a facade for all reporters and it is used to initialize and manage them.
17
18
  It is also used to pass configuration and logger to reporters, handle fallback logic and error handling.
@@ -19,19 +20,29 @@ from typing import Union, List
19
20
 
20
21
 
21
22
  class QaseCoreReporter:
22
- def __init__(self, config: ConfigManager):
23
+ def __init__(self, config: ConfigManager, framework: Union[str, None] = None,
24
+ reporter_name: Union[str, None] = None):
23
25
  config.validate_config()
24
26
  self.config = config.config
25
- self.logger = Logger(self.config.debug)
27
+ # Use the logger from ConfigManager instead of creating a new one
28
+ self.logger = config.logger
26
29
  self._execution_plan = None
27
30
  self.profilers = []
28
31
  self.overhead = 0
29
32
 
33
+ # Initialize status mapping
34
+ self.status_mapping = StatusMapping.from_dict(self.config.status_mapping)
35
+ if not self.status_mapping.is_empty():
36
+ self.logger.log_debug(f"Status mapping initialized: {self.status_mapping}")
37
+
30
38
  # self._selective_execution_setup()
31
39
  self.fallback = self._fallback_setup()
32
40
 
33
41
  self.logger.log_debug(f"Config: {self.config}")
34
42
 
43
+ host_data = get_host_info(framework, reporter_name)
44
+ self.logger.log_debug(f"Host data: {host_data}")
45
+
35
46
  # Reading reporter mode from config file
36
47
  mode = self.config.mode
37
48
 
@@ -61,14 +72,15 @@ class QaseCoreReporter:
61
72
  self.logger.log('Failed to start run, disabling reporting', 'info')
62
73
  self.logger.log(e, 'error')
63
74
  self.reporter = None
75
+ return None
76
+
77
+ return None
64
78
 
65
79
  def complete_run(self) -> None:
66
80
  if self.reporter:
67
81
  try:
68
82
  ts = time.time()
69
- self.logger.log_debug("Completing run")
70
83
  self.reporter.complete_run()
71
- self.logger.log_debug("Run completed")
72
84
  self.overhead += time.time() - ts
73
85
  self.logger.log(f"Overhead for Qase Report: {round(self.overhead * 1000)}ms", 'info')
74
86
  except Exception as e:
@@ -81,7 +93,12 @@ class QaseCoreReporter:
81
93
  try:
82
94
  ts = time.time()
83
95
  self.logger.log_debug(f"Adding result {result}")
96
+
97
+ # Apply status mapping before adding result
98
+ self._apply_status_mapping(result)
99
+
84
100
  self.reporter.add_result(result)
101
+
85
102
  self.logger.log_debug(f"Result {result.get_title()} added")
86
103
  self.overhead += time.time() - ts
87
104
  except Exception as e:
@@ -118,8 +135,9 @@ class QaseCoreReporter:
118
135
  from ..profilers import SleepProfiler
119
136
  self.profilers.append(SleepProfiler(runtime=runtime))
120
137
  if profiler == "db":
121
- from ..profilers import DbProfiler
122
- self.profilers.append(DbProfiler(runtime=runtime))
138
+ from ..profilers import DatabaseProfilerSingleton
139
+ DatabaseProfilerSingleton.init(runtime=runtime)
140
+ self.profilers.append(DatabaseProfilerSingleton.get_instance())
123
141
 
124
142
  def enable_profilers(self) -> None:
125
143
  if self.reporter:
@@ -180,7 +198,7 @@ class QaseCoreReporter:
180
198
  host=self.config.testops.api.host
181
199
  )
182
200
  self._execution_plan = loader.load(self.config.testops.project,
183
- int(self.config.testops.plan.id))
201
+ int(self.config.testops.plan.id))
184
202
  except Exception as e:
185
203
  self.logger.log('Failed to load test plan from Qase TestOps', 'info')
186
204
  self.logger.log(e, 'error')
@@ -201,3 +219,26 @@ class QaseCoreReporter:
201
219
  if self.config.fallback == Mode.report:
202
220
  return QaseReport(config=self.config, logger=self.logger)
203
221
  return None
222
+
223
+ def _apply_status_mapping(self, result: Result) -> None:
224
+ """
225
+ Apply status mapping to a test result.
226
+
227
+ This method applies the configured status mapping to the result's execution status.
228
+ The mapping is applied before the result is sent to the reporter.
229
+
230
+ Args:
231
+ result: Test result to apply status mapping to
232
+ """
233
+ if self.status_mapping.is_empty():
234
+ return
235
+
236
+ original_status = result.get_status()
237
+ if not original_status:
238
+ return
239
+
240
+ mapped_status = self.status_mapping.apply_mapping(original_status)
241
+
242
+ if mapped_status != original_status:
243
+ result.execution.set_status(mapped_status)
244
+ self.logger.log_debug(f"Status mapped for '{result.get_title()}': {original_status} -> {mapped_status}")
@@ -31,17 +31,16 @@ class QaseReport:
31
31
 
32
32
  def start_run(self):
33
33
  self._check_report_path()
34
- self.start_time = str(time.time())
34
+ self.start_time = str(QaseUtils.get_real_time())
35
35
 
36
36
  def complete_run(self):
37
- self.end_time = str(time.time())
37
+ self.end_time = str(QaseUtils.get_real_time())
38
38
  self._compile_report()
39
39
 
40
40
  def complete_worker(self):
41
41
  pass
42
42
 
43
43
  def add_result(self, result: Result):
44
- result.set_run_id(self.run_id)
45
44
  for attachment in result.attachments:
46
45
  self._persist_attachment(attachment)
47
46
 
@@ -68,18 +67,24 @@ class QaseReport:
68
67
  mode = "w"
69
68
  if isinstance(attachment.content, bytes):
70
69
  mode = "wb"
71
- with open(f"{self.report_path}/attachments/{attachment.id}-{attachment.file_name}", mode) as f:
70
+
71
+ file_path = f"{self.report_path}/attachments/{attachment.id}-{attachment.file_name}"
72
+ with open(file_path, mode) as f:
72
73
  f.write(attachment.content)
73
74
  # Clear content to save memory and avoid double writing
74
75
  attachment.content = None
76
+ attachment.file_path = file_path
77
+
75
78
  elif attachment.file_path:
79
+ file_path = f"{self.report_path}/attachments/{attachment.id}-{attachment.file_name}"
76
80
  shutil.copy2(os.path.abspath(attachment.file_path),
77
81
  f"{self.report_path}/attachments/{attachment.id}-{attachment.file_name}")
82
+ attachment.file_path = file_path
78
83
 
79
84
  def _persist_attachments_in_steps(self, steps: list):
80
85
  for step in steps:
81
- if step.attachments:
82
- for attachment in step.attachments:
86
+ if step.execution.attachments:
87
+ for attachment in step.execution.attachments:
83
88
  self._persist_attachment(attachment)
84
89
  if step.steps:
85
90
  self._persist_attachments_in_steps(step.steps)
@@ -2,9 +2,9 @@ import threading
2
2
  import urllib.parse
3
3
 
4
4
  from datetime import datetime
5
- from typing import List
5
+ from typing import List, Union
6
6
  from .. import Logger, ReporterException
7
- from ..client.api_v1_client import ApiV1Client
7
+ from ..client.api_v2_client import ApiV2Client
8
8
  from ..client.base_api_client import BaseApiClient
9
9
  from ..models import Result
10
10
  from ..models.config.qaseconfig import QaseConfig
@@ -69,10 +69,7 @@ class QaseTestOps:
69
69
  self.client.get_project(self.project_code)
70
70
 
71
71
  def _prepare_client(self) -> BaseApiClient:
72
- if self.config.testops.use_v2:
73
- from ..client.api_v2_client import ApiV2Client
74
- return ApiV2Client(self.config, self.logger)
75
- return ApiV1Client(self.config, self.logger)
72
+ return ApiV2Client(self.config, self.logger)
76
73
 
77
74
  def _send_results_threaded(self, results):
78
75
  try:
@@ -89,15 +86,34 @@ class QaseTestOps:
89
86
 
90
87
  def _send_results(self) -> None:
91
88
  if self.results:
92
- # Acquire semaphore before starting the send operation
93
- self.send_semaphore.acquire()
94
- self.count_running_threads += 1
89
+ # Filter results by status if status_filter is configured
95
90
  results_to_send = self.results.copy()
91
+
92
+ if self.config.testops.status_filter and len(self.config.testops.status_filter) > 0:
93
+ filtered_results = []
94
+ for result in results_to_send:
95
+ result_status = result.get_status()
96
+ if result_status and result_status not in self.config.testops.status_filter:
97
+ filtered_results.append(result)
98
+ else:
99
+ self.logger.log_debug(f"Filtering out result '{result.title}' with status '{result_status}'")
100
+
101
+ results_to_send = filtered_results
102
+ self.logger.log_debug(f"Filtered {len(self.results) - len(results_to_send)} results by status filter")
103
+
104
+ if results_to_send:
105
+ # Acquire semaphore before starting the send operation
106
+ self.send_semaphore.acquire()
107
+ self.count_running_threads += 1
108
+
109
+ # Start a new thread for sending results
110
+ send_thread = threading.Thread(target=self._send_results_threaded, args=(results_to_send,))
111
+ send_thread.start()
112
+ else:
113
+ self.logger.log("No results to send after filtering", "info")
114
+
115
+ # Clear results regardless of filtering
96
116
  self.results = []
97
-
98
- # Start a new thread for sending results
99
- send_thread = threading.Thread(target=self._send_results_threaded, args=(results_to_send,))
100
- send_thread.start()
101
117
  else:
102
118
  self.logger.log("No results to send", "info")
103
119
 
@@ -120,18 +136,37 @@ class QaseTestOps:
120
136
  def complete_run(self) -> None:
121
137
  if len(self.results) > 0:
122
138
  self._send_results()
139
+
140
+ while self.count_running_threads > 0:
141
+ pass
142
+
123
143
  if self.complete_after_run:
124
- while self.count_running_threads > 0:
125
- pass
144
+ self.logger.log_debug("Completing run")
126
145
  self.client.complete_run(self.project_code, self.run_id)
146
+ self.logger.log_debug("Run completed")
147
+
148
+ # Enable public report if configured
149
+ if self.config.testops.show_public_report_link:
150
+ try:
151
+ self.logger.log_debug("Enabling public report")
152
+ public_url = self.client.enable_public_report(self.project_code, self.run_id)
153
+ if public_url:
154
+ self.logger.log(f"Public report link: {public_url}", "info")
155
+ else:
156
+ self.logger.log("Failed to generate public report link", "warning")
157
+ except Exception as e:
158
+ self.logger.log(f"Failed to generate public report link: {e}", "warning")
127
159
 
128
160
  def complete_worker(self) -> None:
129
161
  if len(self.results) > 0:
130
162
  self._send_results()
163
+ while self.count_running_threads > 0:
164
+ pass
165
+ self.logger.log_debug("Worker completed")
131
166
 
132
167
  def add_result(self, result: Result) -> None:
133
168
  if result.get_status() == 'failed':
134
- self.__show_link(result.testops_id, result.title)
169
+ self.__show_link(result.testops_ids, result.title)
135
170
  self.results.append(result)
136
171
  if len(self.results) >= self.batch_size:
137
172
  self._send_results()
@@ -142,15 +177,14 @@ class QaseTestOps:
142
177
  def set_results(self, results) -> None:
143
178
  self.results = results
144
179
 
145
- def __show_link(self, id, title: str):
146
- link = self.__prepare_link(id, title)
180
+ def __show_link(self, ids: Union[None, List[int]], title: str):
181
+ link = self.__prepare_link(ids, title)
147
182
  self.logger.log(f"See why this test failed: {link}", "info")
148
183
 
149
- def __prepare_link(self, id, title: str):
184
+ def __prepare_link(self, ids: Union[None, List[int]], title: str):
150
185
  link = f"{self.__baseUrl}/run/{self.project_code}/dashboard/{self.run_id}?source=logs&status=%5B2%5D&search="
151
- if id:
152
- return f"{link}{id}`"
153
-
186
+ if ids is not None and len(ids) > 0:
187
+ return f"{link}{self.project_code}-{ids[0]}"
154
188
  return f"{link}{urllib.parse.quote_plus(title)}"
155
189
 
156
190
  @staticmethod
@@ -0,0 +1,12 @@
1
+ """
2
+ Utilities package for Qase Python Commons.
3
+ """
4
+
5
+ from .status_mapping import StatusMapping, StatusMappingError, create_status_mapping_from_config, create_status_mapping_from_env
6
+
7
+ __all__ = [
8
+ 'StatusMapping',
9
+ 'StatusMappingError',
10
+ 'create_status_mapping_from_config',
11
+ 'create_status_mapping_from_env'
12
+ ]
@@ -0,0 +1,237 @@
1
+ """
2
+ Status mapping utilities for Qase Python Commons.
3
+
4
+ This module provides functionality to map test result statuses from one value to another
5
+ based on configuration. This is useful for standardizing status values across different
6
+ testing frameworks or for custom status transformations.
7
+ """
8
+
9
+ from typing import Dict, Optional, List
10
+ import os
11
+ import logging
12
+
13
+
14
+ class StatusMappingError(Exception):
15
+ """Exception raised when status mapping encounters an error."""
16
+ pass
17
+
18
+
19
+ class StatusMapping:
20
+ """
21
+ Handles mapping of test result statuses.
22
+
23
+ This class provides functionality to:
24
+ - Parse status mapping from configuration
25
+ - Validate status mappings
26
+ - Apply status mappings to test results
27
+ - Support both JSON configuration and environment variables
28
+ """
29
+
30
+ # Valid statuses that can be mapped
31
+ VALID_STATUSES = {
32
+ 'passed', 'failed', 'skipped', 'disabled', 'blocked', 'invalid'
33
+ }
34
+
35
+ def __init__(self, mapping: Optional[Dict[str, str]] = None):
36
+ """
37
+ Initialize StatusMapping with optional mapping dictionary.
38
+
39
+ Args:
40
+ mapping: Dictionary mapping source status to target status
41
+ """
42
+ self.mapping = mapping or {}
43
+ self.logger = logging.getLogger(__name__)
44
+
45
+ @classmethod
46
+ def from_dict(cls, mapping_dict: Dict[str, str]) -> 'StatusMapping':
47
+ """
48
+ Create StatusMapping from dictionary.
49
+
50
+ Args:
51
+ mapping_dict: Dictionary with status mappings
52
+
53
+ Returns:
54
+ StatusMapping instance
55
+
56
+ Raises:
57
+ StatusMappingError: If mapping contains invalid statuses
58
+ """
59
+ instance = cls()
60
+ instance.set_mapping(mapping_dict)
61
+ return instance
62
+
63
+ @classmethod
64
+ def from_env_string(cls, env_string: str) -> 'StatusMapping':
65
+ """
66
+ Create StatusMapping from environment variable string.
67
+
68
+ Expected format: "source1=target1,source2=target2"
69
+
70
+ Args:
71
+ env_string: Environment variable string
72
+
73
+ Returns:
74
+ StatusMapping instance
75
+
76
+ Raises:
77
+ StatusMappingError: If string format is invalid
78
+ """
79
+ instance = cls()
80
+ instance.parse_env_string(env_string)
81
+ return instance
82
+
83
+ def set_mapping(self, mapping_dict: Dict[str, str]) -> None:
84
+ """
85
+ Set status mapping from dictionary.
86
+
87
+ Args:
88
+ mapping_dict: Dictionary with status mappings
89
+
90
+ Raises:
91
+ StatusMappingError: If mapping contains invalid statuses
92
+ """
93
+ if not isinstance(mapping_dict, dict):
94
+ raise StatusMappingError("Mapping must be a dictionary")
95
+
96
+ # Validate all statuses in the mapping
97
+ for source_status, target_status in mapping_dict.items():
98
+ if source_status not in self.VALID_STATUSES:
99
+ raise StatusMappingError(f"Invalid source status: {source_status}")
100
+ if target_status not in self.VALID_STATUSES:
101
+ raise StatusMappingError(f"Invalid target status: {target_status}")
102
+
103
+ self.mapping = mapping_dict.copy()
104
+ self.logger.debug(f"Status mapping set: {self.mapping}")
105
+
106
+ def parse_env_string(self, env_string: str) -> None:
107
+ """
108
+ Parse status mapping from environment variable string.
109
+
110
+ Expected format: "source1=target1,source2=target2"
111
+
112
+ Args:
113
+ env_string: Environment variable string
114
+
115
+ Raises:
116
+ StatusMappingError: If string format is invalid
117
+ """
118
+ if not env_string or not env_string.strip():
119
+ self.mapping = {}
120
+ return
121
+
122
+ mapping_dict = {}
123
+ pairs = env_string.split(',')
124
+
125
+ for pair in pairs:
126
+ pair = pair.strip()
127
+ if not pair:
128
+ continue
129
+
130
+ if '=' not in pair:
131
+ raise StatusMappingError(f"Invalid mapping format: {pair}. Expected 'source=target'")
132
+
133
+ source_status, target_status = pair.split('=', 1)
134
+ source_status = source_status.strip()
135
+ target_status = target_status.strip()
136
+
137
+ if not source_status or not target_status:
138
+ raise StatusMappingError(f"Empty status in mapping: {pair}")
139
+
140
+ mapping_dict[source_status] = target_status
141
+
142
+ self.set_mapping(mapping_dict)
143
+
144
+ def apply_mapping(self, status: str) -> str:
145
+ """
146
+ Apply status mapping to a given status.
147
+
148
+ Args:
149
+ status: Original status
150
+
151
+ Returns:
152
+ Mapped status if mapping exists, otherwise original status
153
+ """
154
+ if not status:
155
+ return status
156
+
157
+ if status in self.mapping:
158
+ mapped_status = self.mapping[status]
159
+ self.logger.debug(f"Status mapped: {status} -> {mapped_status}")
160
+ return mapped_status
161
+
162
+ return status
163
+
164
+ def get_mapping(self) -> Dict[str, str]:
165
+ """
166
+ Get current status mapping.
167
+
168
+ Returns:
169
+ Dictionary with current status mappings
170
+ """
171
+ return self.mapping.copy()
172
+
173
+ def is_empty(self) -> bool:
174
+ """
175
+ Check if mapping is empty.
176
+
177
+ Returns:
178
+ True if no mappings are defined
179
+ """
180
+ return len(self.mapping) == 0
181
+
182
+ def validate(self) -> List[str]:
183
+ """
184
+ Validate current mapping and return any issues.
185
+
186
+ Returns:
187
+ List of validation error messages
188
+ """
189
+ errors = []
190
+
191
+ for source_status, target_status in self.mapping.items():
192
+ if source_status not in self.VALID_STATUSES:
193
+ errors.append(f"Invalid source status: {source_status}")
194
+ if target_status not in self.VALID_STATUSES:
195
+ errors.append(f"Invalid target status: {target_status}")
196
+
197
+ return errors
198
+
199
+ def __str__(self) -> str:
200
+ """String representation of the mapping."""
201
+ return str(self.mapping)
202
+
203
+ def __repr__(self) -> str:
204
+ """Detailed string representation."""
205
+ return f"StatusMapping({self.mapping})"
206
+
207
+
208
+ def create_status_mapping_from_config(config_value: Optional[Dict[str, str]]) -> StatusMapping:
209
+ """
210
+ Create StatusMapping from configuration value.
211
+
212
+ Args:
213
+ config_value: Configuration dictionary or None
214
+
215
+ Returns:
216
+ StatusMapping instance
217
+ """
218
+ if config_value is None:
219
+ return StatusMapping()
220
+
221
+ return StatusMapping.from_dict(config_value)
222
+
223
+
224
+ def create_status_mapping_from_env(env_var_name: str = 'STATUS_MAPPING') -> StatusMapping:
225
+ """
226
+ Create StatusMapping from environment variable.
227
+
228
+ Args:
229
+ env_var_name: Name of environment variable
230
+
231
+ Returns:
232
+ StatusMapping instance
233
+ """
234
+ env_value = os.getenv(env_var_name)
235
+ if env_value:
236
+ return StatusMapping.from_env_string(env_value)
237
+ return StatusMapping()
@@ -0,0 +1,9 @@
1
+ from .host_data import get_host_info
2
+
3
+ __all__ = [
4
+ get_host_info
5
+ ]
6
+
7
+
8
+
9
+