qase-python-commons 5.1.5__tar.gz → 5.1.7__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 (55) hide show
  1. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/PKG-INFO +3 -2
  2. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/README.md +2 -1
  3. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/pyproject.toml +1 -1
  4. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/client/api_v1_client.py +2 -2
  5. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/client/api_v2_client.py +2 -2
  6. qase_python_commons-5.1.7/src/qase/commons/config.py +396 -0
  7. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/models/config/batch.py +2 -1
  8. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/models/run.py +8 -11
  9. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/reporters/core.py +25 -11
  10. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/reporters/report.py +7 -1
  11. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/reporters/testops.py +5 -9
  12. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/reporters/testops_multi.py +6 -9
  13. qase_python_commons-5.1.7/src/qase/commons/util/api_host.py +24 -0
  14. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase_python_commons.egg-info/PKG-INFO +3 -2
  15. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase_python_commons.egg-info/SOURCES.txt +1 -0
  16. qase_python_commons-5.1.5/src/qase/commons/config.py +0 -399
  17. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/setup.cfg +0 -0
  18. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/__init__.py +0 -0
  19. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/__init__.py +0 -0
  20. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/client/base_api_client.py +0 -0
  21. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/exceptions/reporter.py +0 -0
  22. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/loader.py +0 -0
  23. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/logger.py +0 -0
  24. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/models/__init__.py +0 -0
  25. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/models/attachment.py +0 -0
  26. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/models/basemodel.py +0 -0
  27. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/models/config/api.py +0 -0
  28. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/models/config/connection.py +0 -0
  29. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/models/config/framework.py +0 -0
  30. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/models/config/plan.py +0 -0
  31. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/models/config/qaseconfig.py +0 -0
  32. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/models/config/report.py +0 -0
  33. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/models/config/run.py +0 -0
  34. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/models/config/testops.py +0 -0
  35. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/models/external_link.py +0 -0
  36. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/models/relation.py +0 -0
  37. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/models/result.py +0 -0
  38. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/models/runtime.py +0 -0
  39. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/models/step.py +0 -0
  40. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/profilers/__init__.py +0 -0
  41. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/profilers/db.py +0 -0
  42. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/profilers/network.py +0 -0
  43. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/profilers/sleep.py +0 -0
  44. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/reporters/__init__.py +0 -0
  45. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/retry.py +0 -0
  46. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/status_mapping/__init__.py +0 -0
  47. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/status_mapping/status_mapping.py +0 -0
  48. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/util/__init__.py +0 -0
  49. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/util/host_data.py +0 -0
  50. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/util/token_masker.py +0 -0
  51. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/utils.py +0 -0
  52. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase/commons/validators/base.py +0 -0
  53. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase_python_commons.egg-info/dependency_links.txt +0 -0
  54. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/src/qase_python_commons.egg-info/requires.txt +0 -0
  55. {qase_python_commons-5.1.5 → qase_python_commons-5.1.7}/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: 5.1.5
3
+ Version: 5.1.7
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
@@ -137,7 +137,7 @@ The reporter mode is set via the `mode` option:
137
137
  | Description | Config file | Environment variable | Default | Required |
138
138
  |-------------|-------------|---------------------|---------|----------|
139
139
  | API token | `testops.api.token` | `QASE_TESTOPS_API_TOKEN` | — | Yes* |
140
- | API host | `testops.api.host` | `QASE_TESTOPS_API_HOST` | `qase.io` | No |
140
+ | Qase API host. `qase.io` is the US cluster; set `eu.qase.io` if your workspace is on the EU cluster (you open Qase at `app-eu.qase.io`) | `testops.api.host` | `QASE_TESTOPS_API_HOST` | `qase.io` | Yes, on the EU cluster |
141
141
  | Request timeout, seconds | `testops.api.timeout` | `QASE_TESTOPS_API_TIMEOUT` | `30` | No |
142
142
  | Upload attempts | `testops.api.retries` | `QASE_TESTOPS_API_RETRIES` | `3` | No |
143
143
  | Retry backoff base, seconds | `testops.api.retryBackoff` | `QASE_TESTOPS_API_RETRY_BACKOFF` | `2` | No |
@@ -297,6 +297,7 @@ export QASE_DEBUG="false"
297
297
 
298
298
  # TestOps
299
299
  export QASE_TESTOPS_API_TOKEN="<token>"
300
+ export QASE_TESTOPS_API_HOST="qase.io" # eu.qase.io for the EU cluster
300
301
  export QASE_TESTOPS_PROJECT="DEMO"
301
302
  export QASE_TESTOPS_RUN_TITLE="Automated Run"
302
303
  export QASE_TESTOPS_RUN_COMPLETE="true"
@@ -102,7 +102,7 @@ The reporter mode is set via the `mode` option:
102
102
  | Description | Config file | Environment variable | Default | Required |
103
103
  |-------------|-------------|---------------------|---------|----------|
104
104
  | API token | `testops.api.token` | `QASE_TESTOPS_API_TOKEN` | — | Yes* |
105
- | API host | `testops.api.host` | `QASE_TESTOPS_API_HOST` | `qase.io` | No |
105
+ | Qase API host. `qase.io` is the US cluster; set `eu.qase.io` if your workspace is on the EU cluster (you open Qase at `app-eu.qase.io`) | `testops.api.host` | `QASE_TESTOPS_API_HOST` | `qase.io` | Yes, on the EU cluster |
106
106
  | Request timeout, seconds | `testops.api.timeout` | `QASE_TESTOPS_API_TIMEOUT` | `30` | No |
107
107
  | Upload attempts | `testops.api.retries` | `QASE_TESTOPS_API_RETRIES` | `3` | No |
108
108
  | Retry backoff base, seconds | `testops.api.retryBackoff` | `QASE_TESTOPS_API_RETRY_BACKOFF` | `2` | No |
@@ -262,6 +262,7 @@ export QASE_DEBUG="false"
262
262
 
263
263
  # TestOps
264
264
  export QASE_TESTOPS_API_TOKEN="<token>"
265
+ export QASE_TESTOPS_API_HOST="qase.io" # eu.qase.io for the EU cluster
265
266
  export QASE_TESTOPS_PROJECT="DEMO"
266
267
  export QASE_TESTOPS_RUN_TITLE="Automated Run"
267
268
  export QASE_TESTOPS_RUN_COMPLETE="true"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qase-python-commons"
7
- version = "5.1.5"
7
+ version = "5.1.7"
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"}]
@@ -9,6 +9,7 @@ from qase.api_client_v1.configuration import Configuration
9
9
  from .. import Logger
10
10
  from .base_api_client import BaseApiClient
11
11
  from ..retry import send_with_retry
12
+ from ..util.api_host import get_web_app_url
12
13
  from ..exceptions.reporter import ReporterException
13
14
  from ..models import Attachment
14
15
  from ..models.config.framework import Video, Trace
@@ -31,12 +32,11 @@ class ApiV1Client(BaseApiClient):
31
32
  # pinned off so it cannot multiply with the application-level one.
32
33
  configuration.retries = 0
33
34
  host = self.config.testops.api.host
35
+ self.web = get_web_app_url(host)
34
36
  if host == 'qase.io':
35
37
  configuration.host = f'https://api.{host}/v1'
36
- self.web = f'https://app.{host}'
37
38
  else:
38
39
  configuration.host = f'https://api-{host}/v1'
39
- self.web = f'https://{host}'
40
40
 
41
41
  self.client = ApiClient(configuration)
42
42
  self.logger.log_debug("API client prepared")
@@ -20,6 +20,7 @@ from ..models.config.framework import Video, Trace
20
20
  from ..models import Attachment, Result
21
21
  from ..models.config.qaseconfig import QaseConfig
22
22
  from ..models.step import StepType, Step
23
+ from ..util.api_host import get_web_app_url
23
24
  from ..util.host_data import HostData
24
25
 
25
26
 
@@ -42,12 +43,11 @@ class ApiV2Client(ApiV1Client):
42
43
  # 9 requests, the opposite of what is wanted on a 429.
43
44
  configuration.retries = 0
44
45
  host = self.config.testops.api.host
46
+ self.web = get_web_app_url(host)
45
47
  if host == 'qase.io':
46
48
  configuration.host = f'https://api.{host}/v2'
47
- self.web = f'https://app.{host}'
48
49
  else:
49
50
  configuration.host = f'https://api-{host}/v2'
50
- self.web = f'https://{host}'
51
51
 
52
52
  self.client_v2 = ApiClient(configuration)
53
53
 
@@ -0,0 +1,396 @@
1
+ import os
2
+ import json
3
+ from contextlib import contextmanager
4
+ from .logger import Logger, LoggingOptions
5
+ from .models.config.qaseconfig import QaseConfig, Mode
6
+ from .utils import QaseUtils
7
+
8
+
9
+ class ConfigManager:
10
+
11
+ def __init__(self, config_file='./qase.config.json'):
12
+ self.__config_file = config_file
13
+ self.config = QaseConfig()
14
+
15
+ # Initialize temporary logger for error handling during config loading
16
+ self.logger = Logger(debug=False)
17
+
18
+ self.__load_file_config()
19
+ self.__load_env_config()
20
+
21
+ # Re-initialize logger with proper logging options after config is loaded
22
+ logging_options = LoggingOptions(
23
+ console=self.config.logging.console if self.config.logging.console is not None else True,
24
+ file=self.config.logging.file if self.config.logging.file is not None else self.config.debug
25
+ )
26
+ self.logger = Logger(debug=self.config.debug, logging_options=logging_options)
27
+
28
+ def validate_config(self):
29
+ errors: list[str] = []
30
+ if self.config.mode is Mode.testops or self.config.fallback is Mode.testops:
31
+ if self.config.testops.api.token is None:
32
+ errors.append("Testops token is not set")
33
+
34
+ if self.config.testops.project is None:
35
+ errors.append("Testops project is not set")
36
+
37
+ if self.config.mode is Mode.testops_multi or self.config.fallback is Mode.testops_multi:
38
+ if self.config.testops.api.token is None:
39
+ errors.append("Testops token is not set")
40
+
41
+ if not self.config.testops_multi.projects or len(self.config.testops_multi.projects) == 0:
42
+ errors.append("Testops multi: at least one project must be configured")
43
+
44
+ for project in self.config.testops_multi.projects:
45
+ if not project.code:
46
+ errors.append(f"Testops multi: project code is required for all projects")
47
+
48
+ if len(errors) > 0:
49
+ self.logger.log("Config validation failed", "error")
50
+ for error in errors:
51
+ self.logger.log(error, "error")
52
+ raise Exception("Config validation failed")
53
+
54
+ def __str__(self):
55
+ return json.dumps(self.config, indent=4, sort_keys=True)
56
+
57
+ @contextmanager
58
+ def __option(self, name: str):
59
+ # One invalid value must not discard the settings loaded after it.
60
+ try:
61
+ yield
62
+ except Exception as e:
63
+ self.logger.log(f"Ignoring invalid value for {name}: {e}", "error")
64
+
65
+ def __load_file_config(self):
66
+ if not os.path.exists(self.__config_file):
67
+ return
68
+
69
+ try:
70
+ with open(self.__config_file, "r") as file:
71
+ config = json.load(file)
72
+ except Exception as e:
73
+ self.logger.log(f"Failed to load config from file: {e}", "error")
74
+ return
75
+
76
+ with self.__option("mode"):
77
+ if config.get("mode"):
78
+ self.config.set_mode(config.get("mode"))
79
+
80
+ with self.__option("fallback"):
81
+ if config.get("fallback"):
82
+ self.config.set_fallback(config.get("fallback"))
83
+
84
+ with self.__option("environment"):
85
+ if config.get("environment"):
86
+ self.config.set_environment(config.get("environment"))
87
+
88
+ with self.__option("rootSuite"):
89
+ if config.get("rootSuite"):
90
+ self.config.set_root_suite(config.get("rootSuite"))
91
+
92
+ with self.__option("profilers"):
93
+ if config.get("profilers"):
94
+ self.config.set_profilers(config.get("profilers"))
95
+
96
+ with self.__option("debug"):
97
+ if config.get("debug") is not None:
98
+ self.config.set_debug(config.get("debug"))
99
+
100
+ with self.__option("excludeParams"):
101
+ if config.get("excludeParams"):
102
+ self.config.set_exclude_params(config.get("excludeParams"))
103
+
104
+ with self.__option("statusMapping"):
105
+ if config.get("statusMapping"):
106
+ self.config.set_status_mapping(config.get("statusMapping"))
107
+
108
+ with self.__option("executionPlan.path"):
109
+ execution_plan = config.get("executionPlan") or {}
110
+ if execution_plan.get("path"):
111
+ self.config.execution_plan.set_path(execution_plan.get("path"))
112
+
113
+ testops = config.get("testops") or {}
114
+ api = testops.get("api") or {}
115
+
116
+ with self.__option("testops.api.host"):
117
+ if api.get("host"):
118
+ self.config.testops.api.set_host(api.get("host"))
119
+
120
+ with self.__option("testops.api.token"):
121
+ if api.get("token"):
122
+ self.config.testops.api.set_token(api.get("token"))
123
+
124
+ with self.__option("testops.api.timeout"):
125
+ if api.get("timeout"):
126
+ self.config.testops.api.set_timeout(api.get("timeout"))
127
+
128
+ # `is not None`, not truthiness: 0 is a valid value
129
+ # for both, meaning "no retries" / "no backoff".
130
+ with self.__option("testops.api.retries"):
131
+ if api.get("retries") is not None:
132
+ self.config.testops.api.set_retries(api.get("retries"))
133
+
134
+ with self.__option("testops.api.retryBackoff"):
135
+ if api.get("retryBackoff") is not None:
136
+ self.config.testops.api.set_retry_backoff(api.get("retryBackoff"))
137
+
138
+ with self.__option("testops.project"):
139
+ if testops.get("project"):
140
+ self.config.testops.set_project(testops.get("project"))
141
+
142
+ with self.__option("testops.defect"):
143
+ if testops.get("defect") is not None:
144
+ self.config.testops.set_defect(testops.get("defect"))
145
+
146
+ with self.__option("testops.plan.id"):
147
+ plan = testops.get("plan") or {}
148
+ if plan.get("id"):
149
+ self.config.testops.plan.set_id(plan.get("id"))
150
+
151
+ run = testops.get("run") or {}
152
+
153
+ with self.__option("testops.run.id"):
154
+ if run.get("id"):
155
+ self.config.testops.run.set_id(run.get("id"))
156
+
157
+ with self.__option("testops.run.title"):
158
+ if run.get("title"):
159
+ self.config.testops.run.set_title(run.get("title"))
160
+
161
+ with self.__option("testops.run.description"):
162
+ if run.get("description"):
163
+ self.config.testops.run.set_description(run.get("description"))
164
+
165
+ with self.__option("testops.run.complete"):
166
+ if run.get("complete") is not None:
167
+ self.config.testops.run.set_complete(run.get("complete"))
168
+
169
+ with self.__option("testops.run.tags"):
170
+ if run.get("tags"):
171
+ self.config.testops.run.set_tags([tag.strip() for tag in run.get("tags")])
172
+
173
+ with self.__option("testops.run.externalLink"):
174
+ if run.get("externalLink"):
175
+ self.config.testops.run.set_external_link(run.get("externalLink"))
176
+
177
+ with self.__option("testops.batch.size"):
178
+ batch = testops.get("batch") or {}
179
+ if batch.get("size"):
180
+ self.config.testops.batch.set_size(batch.get("size"))
181
+
182
+ configurations = testops.get("configurations") or {}
183
+
184
+ with self.__option("testops.configurations.values"):
185
+ for value in configurations.get("values") or []:
186
+ if value.get("name") and value.get("value"):
187
+ self.config.testops.configurations.add_value(
188
+ value.get("name"), value.get("value"))
189
+
190
+ with self.__option("testops.configurations.createIfNotExists"):
191
+ if configurations.get("createIfNotExists") is not None:
192
+ self.config.testops.configurations.set_create_if_not_exists(
193
+ configurations.get("createIfNotExists"))
194
+
195
+ with self.__option("testops.statusFilter"):
196
+ status_filter = testops.get("statusFilter")
197
+ if status_filter:
198
+ if isinstance(status_filter, list):
199
+ self.config.testops.set_status_filter(status_filter)
200
+ elif isinstance(status_filter, str):
201
+ # Parse comma-separated string
202
+ self.config.testops.set_status_filter([s.strip() for s in status_filter.split(',')])
203
+
204
+ with self.__option("testops.showPublicReportLink"):
205
+ if testops.get("showPublicReportLink") is not None:
206
+ self.config.testops.set_show_public_report_link(testops.get("showPublicReportLink"))
207
+
208
+ with self.__option("testops_multi"):
209
+ if config.get("testops_multi"):
210
+ self.config.set_testops_multi(config.get("testops_multi"))
211
+
212
+ report = config.get("report") or {}
213
+ connection = report.get("connection") or {}
214
+
215
+ with self.__option("report.driver"):
216
+ if report.get("driver"):
217
+ self.config.report.set_driver(report.get("driver"))
218
+
219
+ with self.__option("report.connection.path"):
220
+ if connection.get("path"):
221
+ self.config.report.connection.set_path(connection.get("path"))
222
+
223
+ with self.__option("report.connection.format"):
224
+ if connection.get("format"):
225
+ self.config.report.connection.set_format(connection.get("format"))
226
+
227
+ pytest = (config.get("framework") or {}).get("pytest") or {}
228
+ xfail_status = pytest.get("xfailStatus") or {}
229
+
230
+ with self.__option("framework.pytest.captureLogs"):
231
+ if pytest.get("captureLogs") is not None:
232
+ self.config.framework.pytest.set_capture_logs(pytest.get("captureLogs"))
233
+
234
+ with self.__option("framework.pytest.xfailStatus.xfail"):
235
+ if xfail_status.get("xfail"):
236
+ self.config.framework.pytest.xfail_status.set_xfail(xfail_status.get("xfail"))
237
+
238
+ with self.__option("framework.pytest.xfailStatus.xpass"):
239
+ if xfail_status.get("xpass"):
240
+ self.config.framework.pytest.xfail_status.set_xpass(xfail_status.get("xpass"))
241
+
242
+ with self.__option("logging"):
243
+ if config.get("logging"):
244
+ self.config.set_logging(config.get("logging"))
245
+
246
+ def __load_env_config(self):
247
+ for key, value in os.environ.items():
248
+ # CI templates like QASE_TESTOPS_RUN_ID=${RUN_ID} leave the variable
249
+ # set but empty; that means "not configured", not an invalid value.
250
+ if not key.startswith('QASE_') or not value.strip():
251
+ continue
252
+ with self.__option(key):
253
+ self.__apply_env_var(key, value)
254
+
255
+ def __apply_env_var(self, key: str, value: str):
256
+ if key == 'QASE_MODE':
257
+ self.config.set_mode(value)
258
+
259
+ if key == 'QASE_FALLBACK':
260
+ self.config.set_fallback(value)
261
+
262
+ if key == 'QASE_ENVIRONMENT':
263
+ self.config.set_environment(value)
264
+
265
+ if key == 'QASE_ROOT_SUITE':
266
+ self.config.set_root_suite(value)
267
+
268
+ if key == 'QASE_PROFILERS':
269
+ self.config.set_profilers(value.split(','))
270
+
271
+ if key == 'QASE_PROFILER_NETWORK_EXCLUDE_HOSTS':
272
+ self.config.network_profiler.set_exclude_hosts(
273
+ [h.strip() for h in value.split(',')]
274
+ )
275
+
276
+ if key == 'QASE_DEBUG':
277
+ self.config.set_debug(value)
278
+
279
+ if key == 'QASE_EXCLUDE_PARAMS':
280
+ self.config.set_exclude_params(
281
+ [param.strip() for param in value.split(',')])
282
+
283
+ if key == 'QASE_STATUS_MAPPING':
284
+ # Parse status mapping from environment variable
285
+ # Format: "source1=target1,source2=target2"
286
+ if value:
287
+ mapping_dict = {}
288
+ pairs = value.split(',')
289
+ for pair in pairs:
290
+ pair = pair.strip()
291
+ if pair and '=' in pair:
292
+ source_status, target_status = pair.split('=', 1)
293
+ mapping_dict[source_status.strip()] = target_status.strip()
294
+ self.config.set_status_mapping(mapping_dict)
295
+
296
+ if key == 'QASE_EXECUTION_PLAN_PATH':
297
+ self.config.execution_plan.set_path(value)
298
+
299
+ if key == 'QASE_TESTOPS_API_HOST':
300
+ self.config.testops.api.set_host(value)
301
+
302
+ if key == 'QASE_TESTOPS_API_TOKEN':
303
+ self.config.testops.api.set_token(value)
304
+
305
+ if key == 'QASE_TESTOPS_API_TIMEOUT':
306
+ self.config.testops.api.set_timeout(value)
307
+
308
+ if key == 'QASE_TESTOPS_API_RETRIES':
309
+ self.config.testops.api.set_retries(value)
310
+
311
+ if key == 'QASE_TESTOPS_API_RETRY_BACKOFF':
312
+ self.config.testops.api.set_retry_backoff(value)
313
+
314
+ if key == 'QASE_TESTOPS_PROJECT':
315
+ self.config.testops.set_project(value)
316
+
317
+ if key == 'QASE_TESTOPS_DEFECT':
318
+ self.config.testops.set_defect(value)
319
+
320
+ if key == 'QASE_TESTOPS_PLAN_ID':
321
+ self.config.testops.plan.set_id(int(value.strip()))
322
+
323
+ if key == 'QASE_TESTOPS_RUN_ID':
324
+ self.config.testops.run.set_id(int(value.strip()))
325
+
326
+ if key == 'QASE_TESTOPS_RUN_TITLE':
327
+ self.config.testops.run.set_title(value)
328
+
329
+ if key == 'QASE_TESTOPS_RUN_DESCRIPTION':
330
+ self.config.testops.run.set_description(value)
331
+
332
+ if key == 'QASE_TESTOPS_RUN_COMPLETE':
333
+ self.config.testops.run.set_complete(value)
334
+
335
+ if key == 'QASE_TESTOPS_RUN_TAGS':
336
+ self.config.testops.run.set_tags(
337
+ [tag.strip() for tag in value.split(',')])
338
+
339
+ if key == 'QASE_TESTOPS_RUN_EXTERNAL_LINK_TYPE':
340
+ if not self.config.testops.run.external_link:
341
+ from .models.external_link import ExternalLinkConfig
342
+ self.config.testops.run.external_link = ExternalLinkConfig()
343
+ self.config.testops.run.external_link.set_type(value)
344
+
345
+ if key == 'QASE_TESTOPS_RUN_EXTERNAL_LINK_URL':
346
+ if not self.config.testops.run.external_link:
347
+ from .models.external_link import ExternalLinkConfig
348
+ self.config.testops.run.external_link = ExternalLinkConfig()
349
+ self.config.testops.run.external_link.set_link(value)
350
+
351
+ if key == 'QASE_TESTOPS_BATCH_SIZE':
352
+ self.config.testops.batch.set_size(int(value.strip()))
353
+
354
+ if key == 'QASE_TESTOPS_CONFIGURATIONS_VALUES':
355
+ # Parse configurations from environment variable
356
+ # Format: "group1=value1,group2=value2"
357
+ if value:
358
+ config_pairs = value.split(',')
359
+ for pair in config_pairs:
360
+ if '=' in pair:
361
+ name, config_value = pair.split('=', 1)
362
+ self.config.testops.configurations.add_value(name.strip(), config_value.strip())
363
+
364
+ if key == 'QASE_TESTOPS_CONFIGURATIONS_CREATE_IF_NOT_EXISTS':
365
+ self.config.testops.configurations.set_create_if_not_exists(value)
366
+
367
+ if key == 'QASE_TESTOPS_STATUS_FILTER':
368
+ # Parse comma-separated string
369
+ self.config.testops.set_status_filter([s.strip() for s in value.split(',')])
370
+
371
+ if key == 'QASE_TESTOPS_SHOW_PUBLIC_REPORT_LINK':
372
+ self.config.testops.set_show_public_report_link(value)
373
+
374
+ if key == 'QASE_REPORT_DRIVER':
375
+ self.config.report.set_driver(value)
376
+
377
+ if key == 'QASE_REPORT_CONNECTION_PATH':
378
+ self.config.report.connection.set_path(value)
379
+
380
+ if key == 'QASE_REPORT_CONNECTION_FORMAT':
381
+ self.config.report.connection.set_format(value)
382
+
383
+ if key == 'QASE_PYTEST_CAPTURE_LOGS':
384
+ self.config.framework.pytest.set_capture_logs(value)
385
+
386
+ if key == 'QASE_PYTEST_XFAIL_STATUS_XFAIL':
387
+ self.config.framework.pytest.xfail_status.set_xfail(value)
388
+
389
+ if key == 'QASE_PYTEST_XFAIL_STATUS_XPASS':
390
+ self.config.framework.pytest.xfail_status.set_xpass(value)
391
+
392
+ if key == 'QASE_LOGGING_CONSOLE':
393
+ self.config.logging.set_console(QaseUtils.parse_bool(value))
394
+
395
+ if key == 'QASE_LOGGING_FILE':
396
+ self.config.logging.set_file(QaseUtils.parse_bool(value))
@@ -8,6 +8,7 @@ class BatchConfig(BaseModel):
8
8
  self.size = 200
9
9
 
10
10
  def set_size(self, size: int):
11
- if size > 200 or size == 0:
11
+ size = int(size)
12
+ if size > 200 or size < 1:
12
13
  raise ValueError("Batch size should be greater than 0 and no greater than 200")
13
14
  self.size = size
@@ -50,18 +50,18 @@ class Run(BaseModel):
50
50
  title: str,
51
51
  start_time: float,
52
52
  end_time: float,
53
- results: List[str] = [],
54
- threads: List[str] = [],
55
- suites: List[str] = [],
53
+ results: Optional[List[str]] = None,
54
+ threads: Optional[List[str]] = None,
55
+ suites: Optional[List[str]] = None,
56
56
  environment: Optional[str] = None
57
57
  ):
58
58
  self.host_data = None
59
59
  self.title = title
60
60
  self.execution = RunExecution(start_time=start_time, end_time=end_time)
61
61
  self.stats = RunStats()
62
- self.results = results
63
- self.threads = threads
64
- self.suites = suites
62
+ self.results = results if results is not None else []
63
+ self.threads = threads if threads is not None else []
64
+ self.suites = suites if suites is not None else []
65
65
  self.environment = environment
66
66
 
67
67
  def add_result(self, result: dict):
@@ -84,11 +84,8 @@ class Run(BaseModel):
84
84
 
85
85
  def _extract_path_from_relations(self, relations_dict):
86
86
 
87
- titles = []
88
- if "relations" in relations_dict and "suite" in relations_dict["relations"]:
89
- if "data" in relations_dict["relations"]["suite"]:
90
- data_list = relations_dict["relations"]["suite"]["data"]
91
- titles = [item["title"] for item in data_list if "title" in item]
87
+ suite = (relations_dict.get("relations") or {}).get("suite") or {}
88
+ titles = [item["title"] for item in suite.get("data") or [] if "title" in item]
92
89
 
93
90
  path = "/".join(titles)
94
91
 
@@ -179,9 +179,13 @@ class QaseCoreReporter:
179
179
  self.overhead += time.time() - ts
180
180
  return run_id
181
181
  except Exception as e:
182
- self.logger.log('Failed to start run, disabling reporting', 'info')
182
+ self.logger.log('Failed to start run', 'info')
183
183
  self.logger.log(e, 'error')
184
- self.reporter = None
184
+ if self._can_fallback():
185
+ self._run_fallback()
186
+ else:
187
+ self.logger.log('Disabling reporting', 'info')
188
+ self.reporter = None
185
189
  return None
186
190
 
187
191
  return None
@@ -198,6 +202,12 @@ class QaseCoreReporter:
198
202
  self.logger.log('Failed to complete run', 'info')
199
203
  self.logger.log(e, 'error')
200
204
 
205
+ if self._can_fallback() and self._has_undelivered_results():
206
+ self.logger.log('Some results were not delivered, saving all results with the fallback reporter',
207
+ 'info')
208
+ self._run_fallback()
209
+ self.complete_run()
210
+
201
211
  def add_result(self, result: Result) -> None:
202
212
  if self.reporter:
203
213
  try:
@@ -289,21 +299,25 @@ class QaseCoreReporter:
289
299
  def get_execution_plan(self) -> Union[None, List[int]]:
290
300
  return self._execution_plan
291
301
 
302
+ def _can_fallback(self) -> bool:
303
+ # After a failed TestOps init the fallback is already the active reporter;
304
+ # restarting it would wipe the results it has written.
305
+ return self.fallback is not None and self.reporter is not self.fallback
306
+
307
+ def _has_undelivered_results(self) -> bool:
308
+ lost = getattr(self.reporter, 'lost_results', 0)
309
+ if isinstance(lost, dict):
310
+ lost = sum(lost.values())
311
+ return isinstance(lost, int) and lost > 0
312
+
292
313
  def _run_fallback(self) -> None:
293
- if self.fallback:
314
+ if self._can_fallback():
294
315
  try:
295
316
  results = self.reporter.get_results()
296
317
 
297
318
  self.fallback.start_run()
298
319
  self.reporter = self.fallback
299
- # Handle both single project (list) and multi-project (dict) results
300
- if isinstance(results, dict):
301
- # Multi-project mode: results is dict of project -> list of results
302
- for project_code, project_results in results.items():
303
- self.reporter.set_results({project_code: project_results})
304
- else:
305
- # Single project mode: results is list
306
- self.reporter.set_results(results)
320
+ self.reporter.set_results(results)
307
321
  self.fallback = None
308
322
  except Exception as e:
309
323
  # Log error, disable reporting and continue
@@ -112,7 +112,13 @@ class QaseReport:
112
112
  return self.results
113
113
 
114
114
  def set_results(self, results):
115
- self.results = results
115
+ # Called on fallback with what the TestOps reporter held: a list, or
116
+ # a dict of lists per project. The report is compiled from results/,
117
+ # so they have to be written there, not kept in memory.
118
+ if isinstance(results, dict):
119
+ results = [result for project_results in results.values() for result in project_results]
120
+ for result in results:
121
+ self.add_result(result)
116
122
 
117
123
  def _persist_attachment(self, attachment: Attachment):
118
124
  if attachment.content:
@@ -9,6 +9,7 @@ from ..client.base_api_client import BaseApiClient
9
9
  from ..models import Result
10
10
  from ..models.config.qaseconfig import QaseConfig
11
11
  from ..retry import send_with_retry
12
+ from ..util.api_host import get_web_app_url
12
13
 
13
14
  DEFAULT_BATCH_SIZE = 200
14
15
  DEFAULT_THREAD_COUNT = 4
@@ -20,7 +21,7 @@ class QaseTestOps:
20
21
  def __init__(self, config: QaseConfig, logger: Logger, client: BaseApiClient) -> None:
21
22
  self.config = config
22
23
  self.logger = logger
23
- self.__baseUrl = self.__get_host(config.testops.api.host)
24
+ self.__baseUrl = get_web_app_url(config.testops.api.host)
24
25
  self.client = client
25
26
 
26
27
  run_id = self.config.testops.run.id
@@ -39,6 +40,7 @@ class QaseTestOps:
39
40
  self.count_running_threads = 0
40
41
  # Results in batches that could not be delivered after all retries.
41
42
  self.lost_results = 0
43
+ self.undelivered = []
42
44
 
43
45
  environment = self.config.environment
44
46
  if environment:
@@ -87,6 +89,7 @@ class QaseTestOps:
87
89
  # nothing acted on, which is how the loss stayed invisible.
88
90
  with self.lock:
89
91
  self.lost_results += len(results)
92
+ self.undelivered.extend(results)
90
93
  self.logger.log(
91
94
  f"Failed to send {len(results)} results for run {self.run_id} "
92
95
  f"after {self.config.testops.api.retries} attempt(s): {e}",
@@ -196,7 +199,7 @@ class QaseTestOps:
196
199
  self._send_results()
197
200
 
198
201
  def get_results(self) -> List:
199
- return self.results + self.processed
202
+ return self.results + self.processed + self.undelivered
200
203
 
201
204
  def set_results(self, results) -> None:
202
205
  self.results = results
@@ -210,10 +213,3 @@ class QaseTestOps:
210
213
  if ids is not None and len(ids) > 0:
211
214
  return f"{link}{self.project_code}-{ids[0]}"
212
215
  return f"{link}{urllib.parse.quote_plus(title)}"
213
-
214
- @staticmethod
215
- def __get_host(host: str):
216
- if host == 'qase.io':
217
- return 'https://app.qase.io'
218
-
219
- return f'https://{host.replace("api", "app")}'