qase-python-commons 5.1.6__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.6 → qase_python_commons-5.1.7}/PKG-INFO +3 -2
  2. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/README.md +2 -1
  3. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/pyproject.toml +1 -1
  4. qase_python_commons-5.1.7/src/qase/commons/config.py +396 -0
  5. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/models/config/batch.py +2 -1
  6. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/models/run.py +8 -11
  7. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/reporters/core.py +25 -11
  8. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/reporters/report.py +7 -1
  9. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/reporters/testops.py +3 -1
  10. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/reporters/testops_multi.py +4 -1
  11. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase_python_commons.egg-info/PKG-INFO +3 -2
  12. qase_python_commons-5.1.6/src/qase/commons/config.py +0 -399
  13. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/setup.cfg +0 -0
  14. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/__init__.py +0 -0
  15. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/__init__.py +0 -0
  16. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/client/api_v1_client.py +0 -0
  17. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/client/api_v2_client.py +0 -0
  18. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/client/base_api_client.py +0 -0
  19. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/exceptions/reporter.py +0 -0
  20. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/loader.py +0 -0
  21. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/logger.py +0 -0
  22. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/models/__init__.py +0 -0
  23. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/models/attachment.py +0 -0
  24. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/models/basemodel.py +0 -0
  25. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/models/config/api.py +0 -0
  26. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/models/config/connection.py +0 -0
  27. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/models/config/framework.py +0 -0
  28. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/models/config/plan.py +0 -0
  29. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/models/config/qaseconfig.py +0 -0
  30. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/models/config/report.py +0 -0
  31. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/models/config/run.py +0 -0
  32. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/models/config/testops.py +0 -0
  33. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/models/external_link.py +0 -0
  34. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/models/relation.py +0 -0
  35. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/models/result.py +0 -0
  36. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/models/runtime.py +0 -0
  37. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/models/step.py +0 -0
  38. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/profilers/__init__.py +0 -0
  39. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/profilers/db.py +0 -0
  40. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/profilers/network.py +0 -0
  41. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/profilers/sleep.py +0 -0
  42. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/reporters/__init__.py +0 -0
  43. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/retry.py +0 -0
  44. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/status_mapping/__init__.py +0 -0
  45. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/status_mapping/status_mapping.py +0 -0
  46. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/util/__init__.py +0 -0
  47. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/util/api_host.py +0 -0
  48. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/util/host_data.py +0 -0
  49. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/util/token_masker.py +0 -0
  50. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/utils.py +0 -0
  51. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase/commons/validators/base.py +0 -0
  52. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase_python_commons.egg-info/SOURCES.txt +0 -0
  53. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase_python_commons.egg-info/dependency_links.txt +0 -0
  54. {qase_python_commons-5.1.6 → qase_python_commons-5.1.7}/src/qase_python_commons.egg-info/requires.txt +0 -0
  55. {qase_python_commons-5.1.6 → 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.6
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.6"
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"}]
@@ -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:
@@ -40,6 +40,7 @@ class QaseTestOps:
40
40
  self.count_running_threads = 0
41
41
  # Results in batches that could not be delivered after all retries.
42
42
  self.lost_results = 0
43
+ self.undelivered = []
43
44
 
44
45
  environment = self.config.environment
45
46
  if environment:
@@ -88,6 +89,7 @@ class QaseTestOps:
88
89
  # nothing acted on, which is how the loss stayed invisible.
89
90
  with self.lock:
90
91
  self.lost_results += len(results)
92
+ self.undelivered.extend(results)
91
93
  self.logger.log(
92
94
  f"Failed to send {len(results)} results for run {self.run_id} "
93
95
  f"after {self.config.testops.api.retries} attempt(s): {e}",
@@ -197,7 +199,7 @@ class QaseTestOps:
197
199
  self._send_results()
198
200
 
199
201
  def get_results(self) -> List:
200
- return self.results + self.processed
202
+ return self.results + self.processed + self.undelivered
201
203
 
202
204
  def set_results(self, results) -> None:
203
205
  self.results = results
@@ -51,6 +51,7 @@ class QaseTestOpsMulti:
51
51
  # Results in batches that could not be delivered after all retries,
52
52
  # counted per project so one project's loss does not hide another's.
53
53
  self.lost_results: Dict[str, int] = {project.code: 0 for project in self.multi_config.projects}
54
+ self.undelivered: Dict[str, List[Result]] = {project.code: [] for project in self.multi_config.projects}
54
55
 
55
56
  # Initialize environment for each project
56
57
  for project_code, project_data in self.project_configs.items():
@@ -122,6 +123,7 @@ class QaseTestOpsMulti:
122
123
  # nothing acted on, which is how the loss stayed invisible.
123
124
  with self.lock:
124
125
  self.lost_results[project_code] = self.lost_results.get(project_code, 0) + len(results)
126
+ self.undelivered.setdefault(project_code, []).extend(results)
125
127
  self.logger.log(
126
128
  f"Failed to send {len(results)} results for project {project_code}, "
127
129
  f"run {run_id} after {self.config.testops.api.retries} attempt(s): {e}",
@@ -347,7 +349,8 @@ class QaseTestOpsMulti:
347
349
  """Get all results (pending + processed) grouped by project."""
348
350
  all_results = {}
349
351
  for project_code in self.project_configs.keys():
350
- all_results[project_code] = self.project_results[project_code] + self.processed[project_code]
352
+ all_results[project_code] = (self.project_results[project_code] + self.processed[project_code]
353
+ + self.undelivered.get(project_code, []))
351
354
  return all_results
352
355
 
353
356
  def set_results(self, results: Dict[str, List[Result]]) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qase-python-commons
3
- Version: 5.1.6
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"
@@ -1,399 +0,0 @@
1
- import os
2
- import json
3
- from .logger import Logger, LoggingOptions
4
- from .models.config.qaseconfig import QaseConfig, Mode
5
- from .utils import QaseUtils
6
-
7
-
8
- class ConfigManager:
9
-
10
- def __init__(self, config_file='./qase.config.json'):
11
- self.__config_file = config_file
12
- self.config = QaseConfig()
13
-
14
- # Initialize temporary logger for error handling during config loading
15
- self.logger = Logger(debug=False)
16
-
17
- self.__load_file_config()
18
- self.__load_env_config()
19
-
20
- # Re-initialize logger with proper logging options after config is loaded
21
- logging_options = LoggingOptions(
22
- console=self.config.logging.console if self.config.logging.console is not None else True,
23
- file=self.config.logging.file if self.config.logging.file is not None else self.config.debug
24
- )
25
- self.logger = Logger(debug=self.config.debug, logging_options=logging_options)
26
-
27
- def validate_config(self):
28
- errors: list[str] = []
29
- if self.config.mode is Mode.testops or self.config.fallback is Mode.testops:
30
- if self.config.testops.api.token is None:
31
- errors.append("Testops token is not set")
32
-
33
- if self.config.testops.project is None:
34
- errors.append("Testops project is not set")
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
-
47
- if len(errors) > 0:
48
- self.logger.log("Config validation failed", "error")
49
- for error in errors:
50
- self.logger.log(error, "error")
51
- raise Exception("Config validation failed")
52
-
53
- def __str__(self):
54
- return json.dumps(self.config, indent=4, sort_keys=True)
55
-
56
- def __load_file_config(self):
57
- try:
58
- if os.path.exists(self.__config_file):
59
- with open(self.__config_file, "r") as file:
60
- config = json.load(file)
61
-
62
- if config.get("mode"):
63
- self.config.set_mode(config.get("mode"))
64
-
65
- if config.get("fallback"):
66
- self.config.set_fallback(config.get("fallback"))
67
-
68
- if config.get("environment"):
69
- self.config.set_environment(config.get("environment"))
70
-
71
- if config.get("rootSuite"):
72
- self.config.set_root_suite(config.get("rootSuite"))
73
-
74
- if config.get("profilers"):
75
- self.config.set_profilers(config.get("profilers"))
76
-
77
- if config.get("debug") is not None:
78
- self.config.set_debug(
79
- config.get("debug")
80
- )
81
-
82
- if config.get("excludeParams"):
83
- self.config.set_exclude_params(
84
- config.get("excludeParams")
85
- )
86
-
87
- if config.get("statusMapping"):
88
- self.config.set_status_mapping(
89
- config.get("statusMapping")
90
- )
91
-
92
- if config.get("executionPlan"):
93
- execution_plan = config.get("executionPlan")
94
- if execution_plan.get("path"):
95
- self.config.execution_plan.set_path(
96
- execution_plan.get("path"))
97
-
98
- if config.get("testops"):
99
- testops = config.get("testops")
100
-
101
- if testops.get("api"):
102
- api = testops.get("api")
103
-
104
- if api.get("host"):
105
- self.config.testops.api.set_host(
106
- api.get("host"))
107
-
108
- if api.get("token"):
109
- self.config.testops.api.set_token(
110
- api.get("token"))
111
-
112
- if api.get("timeout"):
113
- self.config.testops.api.set_timeout(
114
- api.get("timeout"))
115
-
116
- # `is not None`, not truthiness: 0 is a valid value
117
- # for both, meaning "no retries" / "no backoff".
118
- if api.get("retries") is not None:
119
- self.config.testops.api.set_retries(
120
- api.get("retries"))
121
-
122
- if api.get("retryBackoff") is not None:
123
- self.config.testops.api.set_retry_backoff(
124
- api.get("retryBackoff"))
125
-
126
- if testops.get("project"):
127
- self.config.testops.set_project(
128
- testops.get("project"))
129
-
130
- if testops.get("defect") is not None:
131
- self.config.testops.set_defect(
132
- testops.get("defect")
133
- )
134
-
135
- if testops.get("plan"):
136
- plan = testops.get("plan")
137
-
138
- if plan.get("id"):
139
- self.config.testops.plan.set_id(plan.get("id"))
140
-
141
- if testops.get("run"):
142
- run = testops.get("run")
143
-
144
- if run.get("id"):
145
- self.config.testops.run.set_id(run.get("id"))
146
-
147
- if run.get("title"):
148
- self.config.testops.run.set_title(
149
- run.get("title"))
150
-
151
- if run.get("description"):
152
- self.config.testops.run.set_description(
153
- run.get("description"))
154
-
155
- if run.get("complete") is not None:
156
- self.config.testops.run.set_complete(
157
- run.get("complete")
158
- )
159
-
160
- if run.get("tags"):
161
- self.config.testops.run.set_tags(
162
- [tag.strip() for tag in run.get("tags")])
163
-
164
- if run.get("externalLink"):
165
- self.config.testops.run.set_external_link(
166
- run.get("externalLink"))
167
-
168
- if testops.get("batch"):
169
- batch = testops.get("batch")
170
-
171
- if batch.get("size"):
172
- self.config.testops.batch.set_size(
173
- batch.get("size"))
174
-
175
- if testops.get("configurations"):
176
- configurations = testops.get("configurations")
177
-
178
- if configurations.get("values"):
179
- values = configurations.get("values")
180
- for value in values:
181
- if value.get("name") and value.get("value"):
182
- self.config.testops.configurations.add_value(
183
- value.get("name"), value.get("value"))
184
-
185
- if configurations.get("createIfNotExists") is not None:
186
- self.config.testops.configurations.set_create_if_not_exists(
187
- configurations.get("createIfNotExists"))
188
-
189
- if testops.get("statusFilter"):
190
- status_filter = testops.get("statusFilter")
191
- if isinstance(status_filter, list):
192
- self.config.testops.set_status_filter(status_filter)
193
- elif isinstance(status_filter, str):
194
- # Parse comma-separated string
195
- self.config.testops.set_status_filter([s.strip() for s in status_filter.split(',')])
196
-
197
- if testops.get("showPublicReportLink") is not None:
198
- self.config.testops.set_show_public_report_link(
199
- testops.get("showPublicReportLink")
200
- )
201
-
202
- if config.get("testops_multi"):
203
- self.config.set_testops_multi(config.get("testops_multi"))
204
-
205
- if config.get("report"):
206
- report = config.get("report")
207
-
208
- if report.get("driver"):
209
- self.config.report.set_driver(report.get("driver"))
210
-
211
- if report.get("connection"):
212
- connection = report.get("connection")
213
-
214
- if connection.get("path"):
215
- self.config.report.connection.set_path(
216
- connection.get("path"))
217
-
218
- if connection.get("format"):
219
- self.config.report.connection.set_format(
220
- connection.get("format")
221
- )
222
-
223
- if config.get("framework"):
224
- framework = config.get("framework")
225
-
226
- if framework.get("pytest"):
227
- pytest = framework.get("pytest")
228
-
229
- if pytest.get("captureLogs") is not None:
230
- self.config.framework.pytest.set_capture_logs(
231
- pytest.get("captureLogs")
232
- )
233
-
234
- if pytest.get("xfailStatus"):
235
- xfail_status = pytest.get("xfailStatus")
236
-
237
- if xfail_status.get("xfail"):
238
- self.config.framework.pytest.xfail_status.set_xfail(
239
- xfail_status.get("xfail")
240
- )
241
-
242
- if xfail_status.get("xpass"):
243
- self.config.framework.pytest.xfail_status.set_xpass(
244
- xfail_status.get("xpass")
245
- )
246
-
247
- if config.get("logging"):
248
- self.config.set_logging(config.get("logging"))
249
-
250
- except Exception as e:
251
- self.logger.log("Failed to load config from file", "error")
252
-
253
- def __load_env_config(self):
254
- try:
255
- for key, value in os.environ.items():
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))
397
-
398
- except Exception as e:
399
- self.logger.log(f"Failed to load config from env vars {e}", "error")