qase-python-commons 4.1.10__tar.gz → 5.0.0__tar.gz

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 (51) hide show
  1. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/PKG-INFO +1 -1
  2. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/pyproject.toml +1 -1
  3. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/client/api_v2_client.py +5 -3
  4. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/config.py +14 -0
  5. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/qaseconfig.py +25 -1
  6. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/testops.py +58 -1
  7. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/result.py +41 -0
  8. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/reporters/__init__.py +2 -0
  9. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/reporters/core.py +28 -4
  10. qase_python_commons-5.0.0/src/qase/commons/reporters/testops_multi.py +335 -0
  11. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase_python_commons.egg-info/PKG-INFO +1 -1
  12. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase_python_commons.egg-info/SOURCES.txt +1 -0
  13. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/README.md +0 -0
  14. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/setup.cfg +0 -0
  15. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/__init__.py +0 -0
  16. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/__init__.py +0 -0
  17. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/client/api_v1_client.py +0 -0
  18. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/client/base_api_client.py +0 -0
  19. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/exceptions/reporter.py +0 -0
  20. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/loader.py +0 -0
  21. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/logger.py +0 -0
  22. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/__init__.py +0 -0
  23. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/attachment.py +0 -0
  24. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/basemodel.py +0 -0
  25. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/api.py +0 -0
  26. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/batch.py +0 -0
  27. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/connection.py +0 -0
  28. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/framework.py +0 -0
  29. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/plan.py +0 -0
  30. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/report.py +0 -0
  31. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/run.py +0 -0
  32. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/external_link.py +0 -0
  33. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/relation.py +0 -0
  34. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/run.py +0 -0
  35. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/runtime.py +0 -0
  36. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/step.py +0 -0
  37. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/profilers/__init__.py +0 -0
  38. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/profilers/db.py +0 -0
  39. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/profilers/network.py +0 -0
  40. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/profilers/sleep.py +0 -0
  41. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/reporters/report.py +0 -0
  42. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/reporters/testops.py +0 -0
  43. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/status_mapping/__init__.py +0 -0
  44. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/status_mapping/status_mapping.py +0 -0
  45. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/util/__init__.py +0 -0
  46. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/util/host_data.py +0 -0
  47. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/utils.py +0 -0
  48. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/validators/base.py +0 -0
  49. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase_python_commons.egg-info/dependency_links.txt +0 -0
  50. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase_python_commons.egg-info/requires.txt +0 -0
  51. {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase_python_commons.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qase-python-commons
3
- Version: 4.1.10
3
+ Version: 5.0.0
4
4
  Summary: A library for Qase TestOps and Qase Report
5
5
  Author-email: Qase Team <support@qase.io>
6
6
  Project-URL: Homepage, https://github.com/qase-tms/qase-python/tree/main/qase-python-commons
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qase-python-commons"
7
- version = "4.1.10"
7
+ version = "5.0.0"
8
8
  description = "A library for Qase TestOps and Qase Report"
9
9
  readme = "README.md"
10
10
  authors = [{name = "Qase Team", email = "support@qase.io"}]
@@ -127,10 +127,12 @@ class ApiV2Client(ApiV1Client):
127
127
  def send_results(self, project_code: str, run_id: str, results: []) -> None:
128
128
  api_results = ResultsApi(self.client_v2)
129
129
  results_to_send = [self._prepare_result(project_code, result) for result in results]
130
- self.logger.log_debug(f"Sending results for run {run_id}: {results_to_send}")
131
- api_results.create_results_v2(project_code, run_id,
130
+ # Convert run_id to int as API expects StrictInt
131
+ run_id_int = int(run_id) if isinstance(run_id, str) else run_id
132
+ self.logger.log_debug(f"Sending results for run {run_id_int}: {results_to_send}")
133
+ api_results.create_results_v2(project_code, run_id_int,
132
134
  create_results_request_v2=CreateResultsRequestV2(results=results_to_send))
133
- self.logger.log_debug(f"Results for run {run_id} sent successfully")
135
+ self.logger.log_debug(f"Results for run {run_id_int} sent successfully")
134
136
 
135
137
  def _prepare_result(self, project_code: str, result: Result) -> ResultCreate:
136
138
  attached = []
@@ -33,6 +33,17 @@ class ConfigManager:
33
33
  if self.config.testops.project is None:
34
34
  errors.append("Testops project is not set")
35
35
 
36
+ if self.config.mode is Mode.testops_multi or self.config.fallback is Mode.testops_multi:
37
+ if self.config.testops.api.token is None:
38
+ errors.append("Testops token is not set")
39
+
40
+ if not self.config.testops_multi.projects or len(self.config.testops_multi.projects) == 0:
41
+ errors.append("Testops multi: at least one project must be configured")
42
+
43
+ for project in self.config.testops_multi.projects:
44
+ if not project.code:
45
+ errors.append(f"Testops multi: project code is required for all projects")
46
+
36
47
  if len(errors) > 0:
37
48
  self.logger.log("Config validation failed", "error")
38
49
  for error in errors:
@@ -174,6 +185,9 @@ class ConfigManager:
174
185
  testops.get("showPublicReportLink")
175
186
  )
176
187
 
188
+ if config.get("testops_multi"):
189
+ self.config.set_testops_multi(config.get("testops_multi"))
190
+
177
191
  if config.get("report"):
178
192
  report = config.get("report")
179
193
 
@@ -3,13 +3,14 @@ from typing import List, Dict, Optional
3
3
 
4
4
  from .framework import Framework
5
5
  from .report import ReportConfig
6
- from .testops import TestopsConfig
6
+ from .testops import TestopsConfig, TestopsMultiConfig
7
7
  from ..basemodel import BaseModel
8
8
  from ... import QaseUtils
9
9
 
10
10
 
11
11
  class Mode(Enum):
12
12
  testops = "testops"
13
+ testops_multi = "testops_multi"
13
14
  report = "report"
14
15
  off = "off"
15
16
 
@@ -47,6 +48,7 @@ class QaseConfig(BaseModel):
47
48
  debug: bool = None
48
49
  execution_plan: ExecutionPlan = None
49
50
  testops: TestopsConfig = None
51
+ testops_multi: TestopsMultiConfig = None
50
52
  report: ReportConfig = None
51
53
  profilers: list = None
52
54
  framework: Framework = None
@@ -59,6 +61,7 @@ class QaseConfig(BaseModel):
59
61
  self.fallback = Mode.off
60
62
  self.debug = False
61
63
  self.testops = TestopsConfig()
64
+ self.testops_multi = TestopsMultiConfig()
62
65
  self.report = ReportConfig()
63
66
  self.execution_plan = ExecutionPlan()
64
67
  self.framework = Framework()
@@ -98,3 +101,24 @@ class QaseConfig(BaseModel):
98
101
  self.logging.set_console(QaseUtils.parse_bool(logging_config.get("console")))
99
102
  if logging_config.get("file") is not None:
100
103
  self.logging.set_file(QaseUtils.parse_bool(logging_config.get("file")))
104
+
105
+ def set_testops_multi(self, testops_multi_config: dict):
106
+ """Set testops multi configuration from dictionary"""
107
+ if testops_multi_config:
108
+ if 'default_project' in testops_multi_config:
109
+ self.testops_multi.set_default_project(testops_multi_config['default_project'])
110
+ if 'projects' in testops_multi_config:
111
+ from .testops import ProjectConfig
112
+ projects = []
113
+ for project_data in testops_multi_config['projects']:
114
+ project = ProjectConfig()
115
+ if 'code' in project_data:
116
+ project.set_code(project_data['code'])
117
+ if 'run' in project_data:
118
+ project.set_run(project_data['run'])
119
+ if 'plan' in project_data:
120
+ project.set_plan(project_data['plan'])
121
+ if 'environment' in project_data:
122
+ project.set_environment(project_data['environment'])
123
+ projects.append(project)
124
+ self.testops_multi.set_projects(projects)
@@ -4,7 +4,7 @@ from .plan import PlanConfig
4
4
  from .run import RunConfig
5
5
  from ..basemodel import BaseModel
6
6
  from ... import QaseUtils
7
- from typing import List
7
+ from typing import List, Optional
8
8
 
9
9
 
10
10
  class ConfigurationValue(BaseModel):
@@ -72,3 +72,60 @@ class TestopsConfig(BaseModel):
72
72
 
73
73
  def set_show_public_report_link(self, show_public_report_link):
74
74
  self.show_public_report_link = QaseUtils.parse_bool(show_public_report_link)
75
+
76
+
77
+ class ProjectConfig(BaseModel):
78
+ code: str = None
79
+ run: RunConfig = None
80
+ plan: PlanConfig = None
81
+ environment: Optional[str] = None
82
+
83
+ def __init__(self):
84
+ self.run = RunConfig()
85
+ self.plan = PlanConfig()
86
+ self.environment = None
87
+
88
+ def set_code(self, code: str):
89
+ self.code = code
90
+
91
+ def set_environment(self, environment: str):
92
+ self.environment = environment
93
+
94
+ def set_run(self, run_config: dict):
95
+ """Set run configuration from dictionary"""
96
+ if run_config:
97
+ if 'title' in run_config:
98
+ self.run.set_title(run_config['title'])
99
+ if 'description' in run_config:
100
+ self.run.set_description(run_config['description'])
101
+ if 'complete' in run_config:
102
+ self.run.set_complete(run_config['complete'])
103
+ if 'id' in run_config:
104
+ self.run.set_id(run_config['id'])
105
+ if 'tags' in run_config:
106
+ self.run.set_tags(run_config['tags'])
107
+ if 'externalLink' in run_config:
108
+ self.run.set_external_link(run_config['externalLink'])
109
+
110
+ def set_plan(self, plan_config: dict):
111
+ """Set plan configuration from dictionary"""
112
+ if plan_config and 'id' in plan_config:
113
+ self.plan.set_id(plan_config['id'])
114
+
115
+
116
+ class TestopsMultiConfig(BaseModel):
117
+ default_project: Optional[str] = None
118
+ projects: List[ProjectConfig] = None
119
+
120
+ def __init__(self):
121
+ self.projects = []
122
+ self.default_project = None
123
+
124
+ def set_default_project(self, default_project: str):
125
+ self.default_project = default_project
126
+
127
+ def set_projects(self, projects: List[ProjectConfig]):
128
+ self.projects = projects
129
+
130
+ def add_project(self, project: ProjectConfig):
131
+ self.projects.append(project)
@@ -76,6 +76,7 @@ class Result(BaseModel):
76
76
  self.title: str = title
77
77
  self.signature: str = signature
78
78
  self.testops_ids: Optional[List[int]] = None
79
+ self.testops_project_mapping: Optional[Dict[str, List[int]]] = None
79
80
  self.execution: Type[Execution] = Execution()
80
81
  self.fields: Dict[Type[Field]] = {}
81
82
  self.attachments: List[Attachment] = []
@@ -124,5 +125,45 @@ class Result(BaseModel):
124
125
  def get_testops_ids(self) -> Optional[List[int]]:
125
126
  return self.testops_ids
126
127
 
128
+ def set_testops_project_mapping(self, project_code: str, testops_ids: List[int]) -> None:
129
+ """
130
+ Set testops IDs for a specific project.
131
+
132
+ :param project_code: Code of the project
133
+ :param testops_ids: List of test case IDs for this project
134
+ """
135
+ if self.testops_project_mapping is None:
136
+ self.testops_project_mapping = {}
137
+ self.testops_project_mapping[project_code] = testops_ids
138
+
139
+ def get_testops_project_mapping(self) -> Optional[Dict[str, List[int]]]:
140
+ """
141
+ Get the complete project mapping.
142
+
143
+ :return: Dictionary mapping project codes to lists of test case IDs
144
+ """
145
+ return self.testops_project_mapping
146
+
147
+ def get_testops_ids_for_project(self, project_code: str) -> Optional[List[int]]:
148
+ """
149
+ Get testops IDs for a specific project.
150
+
151
+ :param project_code: Code of the project
152
+ :return: List of test case IDs for the project, or None if not found
153
+ """
154
+ if self.testops_project_mapping is None:
155
+ return None
156
+ return self.testops_project_mapping.get(project_code)
157
+
158
+ def get_projects(self) -> List[str]:
159
+ """
160
+ Get list of all project codes from the mapping.
161
+
162
+ :return: List of project codes
163
+ """
164
+ if self.testops_project_mapping is None:
165
+ return []
166
+ return list(self.testops_project_mapping.keys())
167
+
127
168
  def get_duration(self) -> int:
128
169
  return self.execution.duration
@@ -1,9 +1,11 @@
1
1
  from .testops import QaseTestOps
2
+ from .testops_multi import QaseTestOpsMulti
2
3
  from .report import QaseReport
3
4
  from .core import QaseCoreReporter
4
5
 
5
6
  __all__ = [
6
7
  QaseTestOps,
8
+ QaseTestOpsMulti,
7
9
  QaseReport,
8
10
  QaseCoreReporter,
9
11
  ]
@@ -5,10 +5,11 @@ from ..logger import Logger
5
5
 
6
6
  from .report import QaseReport
7
7
  from .testops import QaseTestOps
8
+ from .testops_multi import QaseTestOpsMulti
8
9
 
9
10
  from ..models import Result, Attachment, Runtime
10
11
  from ..models.config.qaseconfig import Mode
11
- from typing import Union, List
12
+ from typing import Union, List, Dict
12
13
 
13
14
  from ..util import get_host_info
14
15
  from ..status_mapping.status_mapping import StatusMapping
@@ -63,18 +64,34 @@ class QaseCoreReporter:
63
64
  self.logger.log('Failed to initialize TestOps reporter. Using fallback.', 'info')
64
65
  self.logger.log(e, 'error')
65
66
  self.reporter = self.fallback
67
+ elif mode == Mode.testops_multi:
68
+ try:
69
+ # Create API client with host_data for headers
70
+ from ..client.api_v2_client import ApiV2Client
71
+ api_client = ApiV2Client(self.config, self.logger, host_data=host_data,
72
+ framework=framework, reporter_name=reporter_name)
73
+ self.reporter = QaseTestOpsMulti(config=self.config, logger=self.logger, client=api_client)
74
+ except Exception as e:
75
+ self.logger.log('Failed to initialize TestOps Multi reporter. Using fallback.', 'info')
76
+ self.logger.log(e, 'error')
77
+ self.reporter = self.fallback
66
78
  elif mode == Mode.report:
67
79
  self.reporter = QaseReport(config=self.config, logger=self.logger)
68
80
  else:
69
81
  self.reporter = None
70
82
 
71
- def start_run(self) -> Union[str, None]:
83
+ def start_run(self) -> Union[str, Dict[str, str], None]:
72
84
  if self.reporter:
73
85
  try:
74
86
  ts = time.time()
75
87
  self.logger.log_debug("Starting run")
76
88
  run_id = self.reporter.start_run()
77
- self.logger.log_debug(f"Run ID: {run_id}")
89
+ if isinstance(run_id, dict):
90
+ # Multi-project mode returns dict of project -> run_id
91
+ self.logger.log_debug(f"Run IDs: {run_id}")
92
+ else:
93
+ # Single project mode returns single run_id
94
+ self.logger.log_debug(f"Run ID: {run_id}")
78
95
  self.overhead += time.time() - ts
79
96
  return run_id
80
97
  except Exception as e:
@@ -188,7 +205,14 @@ class QaseCoreReporter:
188
205
 
189
206
  self.fallback.start_run()
190
207
  self.reporter = self.fallback
191
- self.reporter.set_results(results)
208
+ # Handle both single project (list) and multi-project (dict) results
209
+ if isinstance(results, dict):
210
+ # Multi-project mode: results is dict of project -> list of results
211
+ for project_code, project_results in results.items():
212
+ self.reporter.set_results({project_code: project_results})
213
+ else:
214
+ # Single project mode: results is list
215
+ self.reporter.set_results(results)
192
216
  self.fallback = None
193
217
  except Exception as e:
194
218
  # Log error, disable reporting and continue
@@ -0,0 +1,335 @@
1
+ import threading
2
+ import urllib.parse
3
+ import copy
4
+
5
+ from datetime import datetime
6
+ from typing import List, Union, Dict, Optional
7
+ from .. import Logger, ReporterException
8
+ from ..client.base_api_client import BaseApiClient
9
+ from ..models import Result
10
+ from ..models.config.qaseconfig import QaseConfig
11
+
12
+ DEFAULT_BATCH_SIZE = 200
13
+ DEFAULT_THREAD_COUNT = 4
14
+
15
+
16
+ class QaseTestOpsMulti:
17
+
18
+ def __init__(self, config: QaseConfig, logger: Logger, client: BaseApiClient) -> None:
19
+ self.config = config
20
+ self.logger = logger
21
+ self.__baseUrl = self.__get_host(config.testops.api.host)
22
+ self.client = client
23
+
24
+ self.multi_config = config.testops_multi
25
+ self.batch_size = min(2000, max(1, int(self.config.testops.batch.size or DEFAULT_BATCH_SIZE)))
26
+ self.send_semaphore = threading.Semaphore(DEFAULT_THREAD_COUNT)
27
+ self.lock = threading.Lock()
28
+ self.count_running_threads = 0
29
+
30
+ # Create dictionary mapping project codes to their configurations
31
+ self.project_configs: Dict[str, Dict] = {}
32
+ for project in self.multi_config.projects:
33
+ self.project_configs[project.code] = {
34
+ 'config': project,
35
+ 'run_id': None,
36
+ 'plan_id': int(project.plan.id) if project.plan and project.plan.id else None,
37
+ 'environment': None,
38
+ 'run_title': None,
39
+ 'run_description': None,
40
+ 'complete_after_run': project.run.complete if project.run else True,
41
+ }
42
+
43
+ # Initialize structures for grouping results by projects
44
+ self.project_results: Dict[str, List[Result]] = {project.code: [] for project in self.multi_config.projects}
45
+ self.project_runs: Dict[str, int] = {} # Store run_id as int
46
+ self.processed: Dict[str, List[Result]] = {project.code: [] for project in self.multi_config.projects}
47
+
48
+ # Initialize environment for each project
49
+ for project_code, project_data in self.project_configs.items():
50
+ project_config = project_data['config']
51
+ environment = project_config.environment or self.config.environment
52
+
53
+ if environment:
54
+ if isinstance(environment, int) or (isinstance(environment, str) and environment.isnumeric()):
55
+ project_data['environment'] = environment
56
+ elif isinstance(environment, str):
57
+ project_data['environment'] = self.client.get_environment(environment, project_code)
58
+
59
+ # Set run title and description for each project
60
+ run_title = project_config.run.title if project_config.run else None
61
+ if run_title and run_title != '':
62
+ project_data['run_title'] = run_title
63
+ else:
64
+ project_data['run_title'] = f"Automated Run {project_code} {str(datetime.now())}"
65
+
66
+ run_description = project_config.run.description if project_config.run else None
67
+ if run_description and run_description != '':
68
+ project_data['run_description'] = run_description
69
+ else:
70
+ project_data['run_description'] = f"Automated Run {project_code} {str(datetime.now())}"
71
+
72
+ # Set run_id if specified in config
73
+ if project_config.run and project_config.run.id:
74
+ project_data['run_id'] = int(project_config.run.id)
75
+
76
+ # Verify that all projects exist in TestOps
77
+ for project_code in self.project_configs.keys():
78
+ self.client.get_project(project_code)
79
+
80
+ def _create_project_result(self, result: Result, project_code: str, testops_ids: Optional[List[int]]) -> Result:
81
+ """
82
+ Create a copy of result with specific testops_ids for a project.
83
+
84
+ :param result: Original result
85
+ :param project_code: Project code
86
+ :param testops_ids: List of test case IDs for this project (can be empty or None)
87
+ :return: Copy of result with testops_ids set
88
+ """
89
+ # Create a deep copy of the result
90
+ project_result = copy.deepcopy(result)
91
+
92
+ # Set testops_ids for this project (can be None or empty list for tests without IDs)
93
+ project_result.testops_ids = testops_ids if testops_ids else None
94
+
95
+ # Clear project mapping as we're sending to specific project
96
+ project_result.testops_project_mapping = None
97
+
98
+ return project_result
99
+
100
+ def _send_results_threaded(self, project_code: str, run_id: Union[str, int], results: List[Result]):
101
+ try:
102
+ # Convert run_id to str for send_results (it will convert to int internally for API)
103
+ run_id_str = str(run_id) if isinstance(run_id, int) else run_id
104
+ self.client.send_results(project_code, run_id_str, results)
105
+ with self.lock:
106
+ self.processed[project_code].extend(results)
107
+ except Exception as e:
108
+ with self.lock:
109
+ self.logger.log(f"Error at sending results for project {project_code}, run {run_id}: {e}", "error")
110
+ raise
111
+ finally:
112
+ self.count_running_threads -= 1
113
+ self.send_semaphore.release()
114
+
115
+ def _send_results_for_project(self, project_code: str) -> None:
116
+ """Send results for a specific project."""
117
+ results = self.project_results[project_code]
118
+ if not results:
119
+ return
120
+
121
+ run_id = self.project_runs.get(project_code)
122
+ if not run_id:
123
+ self.logger.log(f"No run_id for project {project_code}, skipping send", "warning")
124
+ return
125
+
126
+ # Filter results by status if status_filter is configured
127
+ results_to_send = results.copy()
128
+
129
+ if self.config.testops.status_filter and len(self.config.testops.status_filter) > 0:
130
+ filtered_results = []
131
+ for result in results_to_send:
132
+ result_status = result.get_status()
133
+ if result_status and result_status not in self.config.testops.status_filter:
134
+ filtered_results.append(result)
135
+ else:
136
+ self.logger.log_debug(f"Filtering out result '{result.title}' with status '{result_status}' for project {project_code}")
137
+
138
+ results_to_send = filtered_results
139
+ self.logger.log_debug(f"Filtered {len(results) - len(results_to_send)} results by status filter for project {project_code}")
140
+
141
+ if results_to_send:
142
+ # Acquire semaphore before starting the send operation
143
+ self.send_semaphore.acquire()
144
+ self.count_running_threads += 1
145
+
146
+ # Start a new thread for sending results
147
+ # run_id is stored as int, convert to str for thread (will be converted back to int in send_results)
148
+ run_id_for_thread = str(run_id) if isinstance(run_id, int) else run_id
149
+ send_thread = threading.Thread(target=self._send_results_threaded, args=(project_code, run_id_for_thread, results_to_send))
150
+ send_thread.start()
151
+ else:
152
+ self.logger.log(f"No results to send for project {project_code} after filtering", "info")
153
+
154
+ # Clear results regardless of filtering
155
+ self.project_results[project_code] = []
156
+
157
+ def _send_results(self) -> None:
158
+ """Send results for all projects."""
159
+ for project_code in self.project_configs.keys():
160
+ self._send_results_for_project(project_code)
161
+
162
+ def set_run_id(self, project_code: str, run_id: Union[str, int]) -> None:
163
+ """Set run_id for a specific project."""
164
+ if project_code in self.project_runs:
165
+ self.project_runs[project_code] = int(run_id) if isinstance(run_id, str) else run_id
166
+ else:
167
+ self.logger.log(f"Unknown project code: {project_code}", "warning")
168
+
169
+ def start_run(self) -> Dict[str, int]:
170
+ """
171
+ Create or verify test runs for all projects.
172
+
173
+ :return: Dictionary mapping project codes to run IDs (as integers)
174
+ """
175
+ for project_code, project_data in self.project_configs.items():
176
+ run_id = project_data.get('run_id')
177
+ plan_id = project_data.get('plan_id')
178
+ run_title = project_data.get('run_title')
179
+ run_description = project_data.get('run_description')
180
+ environment_id = project_data.get('environment')
181
+
182
+ # If run_id is already set, verify it exists
183
+ if run_id:
184
+ run_id_int = int(run_id) if isinstance(run_id, str) else run_id
185
+ if not self.client.check_test_run(project_code, run_id_int):
186
+ raise ReporterException(f"Unable to find given test run {run_id_int} for project {project_code}.")
187
+ self.project_runs[project_code] = run_id_int
188
+ continue
189
+
190
+ # Create new test run
191
+ if plan_id:
192
+ created_run_id = self.client.create_test_run(
193
+ project_code=project_code,
194
+ title=run_title,
195
+ description=run_description,
196
+ plan_id=plan_id,
197
+ environment_id=environment_id
198
+ )
199
+ else:
200
+ created_run_id = self.client.create_test_run(
201
+ project_code=project_code,
202
+ title=run_title,
203
+ description=run_description,
204
+ environment_id=environment_id
205
+ )
206
+
207
+ # Store run_id as int (API expects int)
208
+ self.project_runs[project_code] = int(created_run_id) if isinstance(created_run_id, str) else created_run_id
209
+ self.logger.log_debug(f"Created test run {self.project_runs[project_code]} for project {project_code}")
210
+
211
+ return self.project_runs.copy()
212
+
213
+ def complete_run(self) -> None:
214
+ """Complete all test runs for all projects."""
215
+ # Send remaining results for all projects
216
+ if any(len(results) > 0 for results in self.project_results.values()):
217
+ self._send_results()
218
+
219
+ # Wait for all send operations to complete
220
+ while self.count_running_threads > 0:
221
+ pass
222
+
223
+ # Complete all test runs
224
+ for project_code, project_data in self.project_configs.items():
225
+ if project_data.get('complete_after_run'):
226
+ run_id = self.project_runs.get(project_code)
227
+ if run_id:
228
+ self.logger.log_debug(f"Completing run {run_id} for project {project_code}")
229
+ self.client.complete_run(project_code, int(run_id))
230
+ self.logger.log_debug(f"Run {run_id} completed for project {project_code}")
231
+
232
+ # Enable public report if configured
233
+ if self.config.testops.show_public_report_link:
234
+ try:
235
+ self.logger.log_debug(f"Enabling public report for project {project_code}")
236
+ public_url = self.client.enable_public_report(project_code, int(run_id))
237
+ if public_url:
238
+ self.logger.log(f"Public report link for {project_code}: {public_url}", "info")
239
+ else:
240
+ self.logger.log(f"Failed to generate public report link for {project_code}", "warning")
241
+ except Exception as e:
242
+ self.logger.log(f"Failed to generate public report link for {project_code}: {e}", "warning")
243
+
244
+ def complete_worker(self) -> None:
245
+ """Complete worker - send remaining results."""
246
+ if any(len(results) > 0 for results in self.project_results.values()):
247
+ self._send_results()
248
+ while self.count_running_threads > 0:
249
+ pass
250
+ self.logger.log_debug("Worker completed")
251
+
252
+ def add_result(self, result: Result) -> None:
253
+ """
254
+ Add result to appropriate projects based on project mapping.
255
+
256
+ :param result: Test result to add
257
+ """
258
+ # Get project mapping from result
259
+ project_mapping = result.get_testops_project_mapping()
260
+
261
+ if not project_mapping:
262
+ # If no mapping, use default project or first project from config
263
+ default_project = self.multi_config.default_project
264
+ if not default_project and self.multi_config.projects:
265
+ # Use first project from config if default_project is not specified
266
+ default_project = self.multi_config.projects[0].code
267
+ self.logger.log_debug(f"No default_project specified, using first project: {default_project}")
268
+
269
+ if default_project:
270
+ testops_ids = result.get_testops_ids() or []
271
+ # Send result even if no testops_ids (test without ID)
272
+ project_mapping = {default_project: testops_ids}
273
+ else:
274
+ self.logger.log(f"No project mapping and no projects configured for result {result.title}", "warning")
275
+ return
276
+
277
+ # Process result for each project in mapping
278
+ for project_code, testops_ids in project_mapping.items():
279
+ if project_code not in self.project_configs:
280
+ self.logger.log(f"Unknown project {project_code} for result {result.title}, skipping", "warning")
281
+ continue
282
+
283
+ # Allow results without testops_ids (tests without IDs)
284
+ # if not testops_ids:
285
+ # self.logger.log_debug(f"No testops_ids for project {project_code} in result {result.title}, skipping")
286
+ # continue
287
+
288
+ # Create project-specific result
289
+ project_result = self._create_project_result(result, project_code, testops_ids)
290
+
291
+ # Show link for failed tests (only if testops_ids are present)
292
+ if project_result.get_status() == 'failed' and testops_ids:
293
+ self.__show_link(project_code, testops_ids, project_result.title)
294
+
295
+ # Add to project queue
296
+ self.project_results[project_code].append(project_result)
297
+
298
+ # Check batch size and send if needed
299
+ if len(self.project_results[project_code]) >= self.batch_size:
300
+ self._send_results_for_project(project_code)
301
+
302
+ def get_results(self) -> Dict[str, List[Result]]:
303
+ """Get all results (pending + processed) grouped by project."""
304
+ all_results = {}
305
+ for project_code in self.project_configs.keys():
306
+ all_results[project_code] = self.project_results[project_code] + self.processed[project_code]
307
+ return all_results
308
+
309
+ def set_results(self, results: Dict[str, List[Result]]) -> None:
310
+ """Set results for projects."""
311
+ for project_code, project_results in results.items():
312
+ if project_code in self.project_results:
313
+ self.project_results[project_code] = project_results
314
+
315
+ def __show_link(self, project_code: str, ids: Union[None, List[int]], title: str):
316
+ """Show link to failed test."""
317
+ link = self.__prepare_link(project_code, ids, title)
318
+ self.logger.log(f"See why this test failed: {link}", "info")
319
+
320
+ def __prepare_link(self, project_code: str, ids: Union[None, List[int]], title: str):
321
+ """Prepare link to test in Qase."""
322
+ run_id = self.project_runs.get(project_code, '')
323
+ # Ensure run_id is converted to string for URL
324
+ run_id_str = str(run_id) if run_id else ''
325
+ link = f"{self.__baseUrl}/run/{project_code}/dashboard/{run_id_str}?source=logs&status=%5B2%5D&search="
326
+ if ids is not None and len(ids) > 0:
327
+ return f"{link}{project_code}-{ids[0]}"
328
+ return f"{link}{urllib.parse.quote_plus(title)}"
329
+
330
+ @staticmethod
331
+ def __get_host(host: str):
332
+ """Get host URL for Qase."""
333
+ if host == 'qase.io':
334
+ return 'https://app.qase.io'
335
+ return f'https://{host.replace("api", "app")}'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qase-python-commons
3
- Version: 4.1.10
3
+ Version: 5.0.0
4
4
  Summary: A library for Qase TestOps and Qase Report
5
5
  Author-email: Qase Team <support@qase.io>
6
6
  Project-URL: Homepage, https://github.com/qase-tms/qase-python/tree/main/qase-python-commons
@@ -36,6 +36,7 @@ src/qase/commons/reporters/__init__.py
36
36
  src/qase/commons/reporters/core.py
37
37
  src/qase/commons/reporters/report.py
38
38
  src/qase/commons/reporters/testops.py
39
+ src/qase/commons/reporters/testops_multi.py
39
40
  src/qase/commons/status_mapping/__init__.py
40
41
  src/qase/commons/status_mapping/status_mapping.py
41
42
  src/qase/commons/util/__init__.py