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.
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/PKG-INFO +1 -1
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/pyproject.toml +1 -1
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/client/api_v2_client.py +5 -3
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/config.py +14 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/qaseconfig.py +25 -1
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/testops.py +58 -1
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/result.py +41 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/reporters/__init__.py +2 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/reporters/core.py +28 -4
- qase_python_commons-5.0.0/src/qase/commons/reporters/testops_multi.py +335 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase_python_commons.egg-info/PKG-INFO +1 -1
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase_python_commons.egg-info/SOURCES.txt +1 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/README.md +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/setup.cfg +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/__init__.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/__init__.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/client/api_v1_client.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/client/base_api_client.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/exceptions/reporter.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/loader.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/logger.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/__init__.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/attachment.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/basemodel.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/api.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/batch.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/connection.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/framework.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/plan.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/report.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/run.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/external_link.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/relation.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/run.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/runtime.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/step.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/profilers/__init__.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/profilers/db.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/profilers/network.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/profilers/sleep.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/reporters/report.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/reporters/testops.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/status_mapping/__init__.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/status_mapping/status_mapping.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/util/__init__.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/util/host_data.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/utils.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/validators/base.py +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase_python_commons.egg-info/dependency_links.txt +0 -0
- {qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase_python_commons.egg-info/requires.txt +0 -0
- {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:
|
|
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 = "
|
|
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"}]
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/client/api_v2_client.py
RENAMED
|
@@ -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
|
-
|
|
131
|
-
|
|
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 {
|
|
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)
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/testops.py
RENAMED
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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")}'
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase_python_commons.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qase-python-commons
|
|
3
|
-
Version:
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/client/api_v1_client.py
RENAMED
|
File without changes
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/client/base_api_client.py
RENAMED
|
File without changes
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/exceptions/reporter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/__init__.py
RENAMED
|
File without changes
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/attachment.py
RENAMED
|
File without changes
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/basemodel.py
RENAMED
|
File without changes
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/api.py
RENAMED
|
File without changes
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/batch.py
RENAMED
|
File without changes
|
|
File without changes
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/framework.py
RENAMED
|
File without changes
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/plan.py
RENAMED
|
File without changes
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/report.py
RENAMED
|
File without changes
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/config/run.py
RENAMED
|
File without changes
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/external_link.py
RENAMED
|
File without changes
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/models/relation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/profilers/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/profilers/network.py
RENAMED
|
File without changes
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/profilers/sleep.py
RENAMED
|
File without changes
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/reporters/report.py
RENAMED
|
File without changes
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/reporters/testops.py
RENAMED
|
File without changes
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/status_mapping/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{qase_python_commons-4.1.10 → qase_python_commons-5.0.0}/src/qase/commons/validators/base.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|