qase-python-commons 5.1.4__tar.gz → 5.1.6__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 (54) hide show
  1. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/PKG-INFO +7 -1
  2. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/README.md +6 -0
  3. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/pyproject.toml +1 -1
  4. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/client/api_v1_client.py +61 -18
  5. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/client/api_v2_client.py +2 -2
  6. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/reporters/testops.py +2 -8
  7. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/reporters/testops_multi.py +2 -8
  8. qase_python_commons-5.1.6/src/qase/commons/util/api_host.py +24 -0
  9. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase_python_commons.egg-info/PKG-INFO +7 -1
  10. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase_python_commons.egg-info/SOURCES.txt +1 -0
  11. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/setup.cfg +0 -0
  12. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/__init__.py +0 -0
  13. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/__init__.py +0 -0
  14. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/client/base_api_client.py +0 -0
  15. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/config.py +0 -0
  16. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/exceptions/reporter.py +0 -0
  17. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/loader.py +0 -0
  18. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/logger.py +0 -0
  19. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/models/__init__.py +0 -0
  20. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/models/attachment.py +0 -0
  21. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/models/basemodel.py +0 -0
  22. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/models/config/api.py +0 -0
  23. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/models/config/batch.py +0 -0
  24. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/models/config/connection.py +0 -0
  25. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/models/config/framework.py +0 -0
  26. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/models/config/plan.py +0 -0
  27. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/models/config/qaseconfig.py +0 -0
  28. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/models/config/report.py +0 -0
  29. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/models/config/run.py +0 -0
  30. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/models/config/testops.py +0 -0
  31. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/models/external_link.py +0 -0
  32. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/models/relation.py +0 -0
  33. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/models/result.py +0 -0
  34. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/models/run.py +0 -0
  35. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/models/runtime.py +0 -0
  36. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/models/step.py +0 -0
  37. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/profilers/__init__.py +0 -0
  38. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/profilers/db.py +0 -0
  39. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/profilers/network.py +0 -0
  40. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/profilers/sleep.py +0 -0
  41. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/reporters/__init__.py +0 -0
  42. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/reporters/core.py +0 -0
  43. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/reporters/report.py +0 -0
  44. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/retry.py +0 -0
  45. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/status_mapping/__init__.py +0 -0
  46. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/status_mapping/status_mapping.py +0 -0
  47. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/util/__init__.py +0 -0
  48. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/util/host_data.py +0 -0
  49. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/util/token_masker.py +0 -0
  50. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/utils.py +0 -0
  51. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase/commons/validators/base.py +0 -0
  52. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase_python_commons.egg-info/dependency_links.txt +0 -0
  53. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/src/qase_python_commons.egg-info/requires.txt +0 -0
  54. {qase_python_commons-5.1.4 → qase_python_commons-5.1.6}/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.4
3
+ Version: 5.1.6
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
@@ -435,6 +435,12 @@ When a 429 carries a `Retry-After` header, that value replaces the computed
435
435
  backoff. Qase sends roughly 60 seconds, so a run that hits the rate limit takes
436
436
  longer to finish rather than losing the batch.
437
437
 
438
+ The same timeout and retry settings apply to attachment uploads, which go
439
+ through the API v1 client in batches of up to 20 files. **If an attachment batch
440
+ cannot be uploaded after all attempts**, the reporter logs an error, drops those
441
+ attachments and still submits the results they belong to — a failed attachment
442
+ never costs you the test results.
443
+
438
444
  **If a batch cannot be delivered after all attempts**, the reporter logs an
439
445
  error naming how many results were lost and **does not mark the run complete**.
440
446
  An open run is the signal that its data is incomplete; a completed run over
@@ -400,6 +400,12 @@ When a 429 carries a `Retry-After` header, that value replaces the computed
400
400
  backoff. Qase sends roughly 60 seconds, so a run that hits the rate limit takes
401
401
  longer to finish rather than losing the batch.
402
402
 
403
+ The same timeout and retry settings apply to attachment uploads, which go
404
+ through the API v1 client in batches of up to 20 files. **If an attachment batch
405
+ cannot be uploaded after all attempts**, the reporter logs an error, drops those
406
+ attachments and still submits the results they belong to — a failed attachment
407
+ never costs you the test results.
408
+
403
409
  **If a batch cannot be delivered after all attempts**, the reporter logs an
404
410
  error naming how many results were lost and **does not mark the run complete**.
405
411
  An open run is the signal that its data is incomplete; a completed run over
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qase-python-commons"
7
- version = "5.1.4"
7
+ version = "5.1.6"
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"}]
@@ -8,6 +8,8 @@ from qase.api_client_v1.models.attachmentupload import Attachmentupload
8
8
  from qase.api_client_v1.configuration import Configuration
9
9
  from .. import Logger
10
10
  from .base_api_client import BaseApiClient
11
+ from ..retry import send_with_retry
12
+ from ..util.api_host import get_web_app_url
11
13
  from ..exceptions.reporter import ReporterException
12
14
  from ..models import Attachment
13
15
  from ..models.config.framework import Video, Trace
@@ -30,12 +32,11 @@ class ApiV1Client(BaseApiClient):
30
32
  # pinned off so it cannot multiply with the application-level one.
31
33
  configuration.retries = 0
32
34
  host = self.config.testops.api.host
35
+ self.web = get_web_app_url(host)
33
36
  if host == 'qase.io':
34
37
  configuration.host = f'https://api.{host}/v1'
35
- self.web = f'https://app.{host}'
36
38
  else:
37
39
  configuration.host = f'https://api-{host}/v1'
38
- self.web = f'https://{host}'
39
40
 
40
41
  self.client = ApiClient(configuration)
41
42
  self.logger.log_debug("API client prepared")
@@ -46,7 +47,9 @@ class ApiV1Client(BaseApiClient):
46
47
  def get_project(self, project_code: str) -> Union[Project, None]:
47
48
  try:
48
49
  self.logger.log_debug(f"Getting project {project_code}")
49
- response = ProjectsApi(self.client).get_project(code=project_code)
50
+ response = ProjectsApi(self.client).get_project(
51
+ code=project_code, _request_timeout=self.config.testops.api.timeout
52
+ )
50
53
  if hasattr(response, 'result'):
51
54
  self.logger.log_debug(f"Project {project_code} found: {response.result.to_json()}")
52
55
  return response.result
@@ -59,7 +62,9 @@ class ApiV1Client(BaseApiClient):
59
62
  try:
60
63
  self.logger.log_debug(f"Getting environment {environment}")
61
64
  api_instance = EnvironmentsApi(self.client)
62
- response = api_instance.get_environments(code=project_code)
65
+ response = api_instance.get_environments(
66
+ code=project_code, _request_timeout=self.config.testops.api.timeout
67
+ )
63
68
  if hasattr(response, 'result') and hasattr(response.result, 'entities'):
64
69
  for env in response.result.entities:
65
70
  if env.slug == environment:
@@ -76,7 +81,9 @@ class ApiV1Client(BaseApiClient):
76
81
  try:
77
82
  self.logger.log_debug(f"Getting configurations for project {project_code}")
78
83
  api_instance = ConfigurationsApi(self.client)
79
- response = api_instance.get_configurations(code=project_code)
84
+ response = api_instance.get_configurations(
85
+ code=project_code, _request_timeout=self.config.testops.api.timeout
86
+ )
80
87
  if hasattr(response, 'result') and hasattr(response.result, 'entities'):
81
88
  return response.result.entities
82
89
  return []
@@ -117,8 +124,9 @@ class ApiV1Client(BaseApiClient):
117
124
  # Create new group
118
125
  group_create = ConfigurationGroupCreate(title=config_value.name)
119
126
  group_response = ConfigurationsApi(self.client).create_configuration_group(
120
- code=project_code,
121
- configuration_group_create=group_create
127
+ code=project_code,
128
+ configuration_group_create=group_create,
129
+ _request_timeout=self.config.testops.api.timeout
122
130
  )
123
131
  group_id = group_response.result.id
124
132
 
@@ -129,7 +137,8 @@ class ApiV1Client(BaseApiClient):
129
137
  )
130
138
  config_response = ConfigurationsApi(self.client).create_configuration(
131
139
  code=project_code,
132
- configuration_create=config_create
140
+ configuration_create=config_create,
141
+ _request_timeout=self.config.testops.api.timeout
133
142
  )
134
143
  config_id = config_response.result.id
135
144
  return config_id
@@ -141,12 +150,16 @@ class ApiV1Client(BaseApiClient):
141
150
  def complete_run(self, project_code: str, run_id: int) -> None:
142
151
  api_runs = RunsApi(self.client)
143
152
  self.logger.log_debug(f"Completing run {run_id}")
144
- res = api_runs.get_run(project_code, run_id).result
153
+ res = api_runs.get_run(
154
+ project_code, run_id, _request_timeout=self.config.testops.api.timeout
155
+ ).result
145
156
  if res.status == 1:
146
157
  self.logger.log_debug(f"Run {run_id} already completed")
147
158
  return
148
159
  try:
149
- api_runs.complete_run(project_code, run_id)
160
+ api_runs.complete_run(
161
+ project_code, run_id, _request_timeout=self.config.testops.api.timeout
162
+ )
150
163
  self.logger.log(f"Test run link: {self.web}/run/{project_code}/dashboard/{run_id}", "info")
151
164
  except Exception as e:
152
165
  self.logger.log(f"Error at completing run {run_id}: {e}", "error")
@@ -240,10 +253,33 @@ class ApiV1Client(BaseApiClient):
240
253
 
241
254
  # Prepare files for upload
242
255
  files_for_upload = [att.get_for_upload() for att in batch]
243
-
244
- # Upload batch
245
- response = attach_api.upload_attachment(project_code, file=files_for_upload)
246
-
256
+
257
+ # The timeout is what keeps a stalled connection from blocking
258
+ # the reporter thread forever: without it urllib3 waits
259
+ # indefinitely, no exception is ever raised, and the retry below
260
+ # never gets a chance to run.
261
+ #
262
+ # Unlike results, an attachment carries no idempotency key, so a
263
+ # retry whose first response was lost can leave an unreferenced
264
+ # file behind. That is cheaper than the alternative: the whole
265
+ # batch of results these attachments belong to going nowhere.
266
+ response = None
267
+
268
+ def upload_batch():
269
+ nonlocal response
270
+ response = attach_api.upload_attachment(
271
+ project_code,
272
+ file=files_for_upload,
273
+ _request_timeout=self.config.testops.api.timeout
274
+ )
275
+
276
+ send_with_retry(
277
+ upload_batch,
278
+ attempts=self.config.testops.api.retries,
279
+ backoff=self.config.testops.api.retry_backoff,
280
+ logger=self.logger,
281
+ )
282
+
247
283
  if response.result:
248
284
  all_uploaded.extend(response.result)
249
285
  self.logger.log_debug(
@@ -294,7 +330,8 @@ class ApiV1Client(BaseApiClient):
294
330
  try:
295
331
  result = RunsApi(self.client).create_run(
296
332
  code=project_code,
297
- run_create=RunCreate(**{k: v for k, v in kwargs.items() if v is not None})
333
+ run_create=RunCreate(**{k: v for k, v in kwargs.items() if v is not None}),
334
+ _request_timeout=self.config.testops.api.timeout
298
335
  )
299
336
 
300
337
  run_id = result.result.id
@@ -311,7 +348,9 @@ class ApiV1Client(BaseApiClient):
311
348
 
312
349
  def check_test_run(self, project_code: str, run_id: int) -> bool:
313
350
  api_runs = RunsApi(self.client)
314
- run = api_runs.get_run(code=project_code, id=run_id)
351
+ run = api_runs.get_run(
352
+ code=project_code, id=run_id, _request_timeout=self.config.testops.api.timeout
353
+ )
315
354
  if run.result.id:
316
355
  return True
317
356
  return False
@@ -332,7 +371,10 @@ class ApiV1Client(BaseApiClient):
332
371
  run_public = RunPublic(status=True)
333
372
 
334
373
  # Call the API to enable public report
335
- response = api_runs.update_run_publicity(project_code, run_id, run_public)
374
+ response = api_runs.update_run_publicity(
375
+ project_code, run_id, run_public,
376
+ _request_timeout=self.config.testops.api.timeout
377
+ )
336
378
 
337
379
  # Extract the public URL from response
338
380
  if response.result and response.result.url:
@@ -368,7 +410,8 @@ class ApiV1Client(BaseApiClient):
368
410
 
369
411
  RunsApi(self.client).run_update_external_issue(
370
412
  code=project_code,
371
- runexternal_issues=run_external_issues
413
+ runexternal_issues=run_external_issues,
414
+ _request_timeout=self.config.testops.api.timeout
372
415
  )
373
416
 
374
417
  self.logger.log(f"External link updated for run {run_id}: {external_link.link}", "debug")
@@ -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
 
@@ -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
@@ -210,10 +211,3 @@ class QaseTestOps:
210
211
  if ids is not None and len(ids) > 0:
211
212
  return f"{link}{self.project_code}-{ids[0]}"
212
213
  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")}'
@@ -10,6 +10,7 @@ from ..client.base_api_client import BaseApiClient
10
10
  from ..models import Result
11
11
  from ..models.config.qaseconfig import QaseConfig
12
12
  from ..retry import send_with_retry
13
+ from ..util.api_host import get_web_app_url
13
14
 
14
15
  DEFAULT_BATCH_SIZE = 200
15
16
  DEFAULT_THREAD_COUNT = 4
@@ -21,7 +22,7 @@ class QaseTestOpsMulti:
21
22
  def __init__(self, config: QaseConfig, logger: Logger, client: BaseApiClient) -> None:
22
23
  self.config = config
23
24
  self.logger = logger
24
- self.__baseUrl = self.__get_host(config.testops.api.host)
25
+ self.__baseUrl = get_web_app_url(config.testops.api.host)
25
26
  self.client = client
26
27
 
27
28
  self.multi_config = config.testops_multi
@@ -369,10 +370,3 @@ class QaseTestOpsMulti:
369
370
  if ids is not None and len(ids) > 0:
370
371
  return f"{link}{project_code}-{ids[0]}"
371
372
  return f"{link}{urllib.parse.quote_plus(title)}"
372
-
373
- @staticmethod
374
- def __get_host(host: str):
375
- """Get host URL for Qase."""
376
- if host == 'qase.io':
377
- return 'https://app.qase.io'
378
- return f'https://{host.replace("api", "app")}'
@@ -0,0 +1,24 @@
1
+ from typing import Optional
2
+
3
+ DEFAULT_HOST = 'qase.io'
4
+
5
+ # Enterprise hosts share the `api-<name>.qase.io` shape but serve the web app on
6
+ # `<name>.qase.io`, so a cluster cannot be inferred from the API hostname and has
7
+ # to be listed explicitly.
8
+ CLUSTER_APP_HOSTS = {
9
+ 'api-eu.qase.io': 'app-eu.qase.io',
10
+ }
11
+
12
+
13
+ def resolve_cluster_app_url(host: str) -> Optional[str]:
14
+ """Return the web app URL of a known Qase cluster, or None for any other host."""
15
+ # Mirrors how a host fragment becomes the API base URL: `https://api-<host>`.
16
+ app_host = CLUSTER_APP_HOSTS.get(f'api-{host}'.lower())
17
+ return f'https://{app_host}' if app_host else None
18
+
19
+
20
+ def get_web_app_url(host: str) -> str:
21
+ """Return the Qase web app base URL for the `testops.api.host` value."""
22
+ if host == DEFAULT_HOST:
23
+ return f'https://app.{DEFAULT_HOST}'
24
+ return resolve_cluster_app_url(host) or f'https://{host}'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qase-python-commons
3
- Version: 5.1.4
3
+ Version: 5.1.6
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
@@ -435,6 +435,12 @@ When a 429 carries a `Retry-After` header, that value replaces the computed
435
435
  backoff. Qase sends roughly 60 seconds, so a run that hits the rate limit takes
436
436
  longer to finish rather than losing the batch.
437
437
 
438
+ The same timeout and retry settings apply to attachment uploads, which go
439
+ through the API v1 client in batches of up to 20 files. **If an attachment batch
440
+ cannot be uploaded after all attempts**, the reporter logs an error, drops those
441
+ attachments and still submits the results they belong to — a failed attachment
442
+ never costs you the test results.
443
+
438
444
  **If a batch cannot be delivered after all attempts**, the reporter logs an
439
445
  error naming how many results were lost and **does not mark the run complete**.
440
446
  An open run is the signal that its data is incomplete; a completed run over
@@ -41,6 +41,7 @@ src/qase/commons/reporters/testops_multi.py
41
41
  src/qase/commons/status_mapping/__init__.py
42
42
  src/qase/commons/status_mapping/status_mapping.py
43
43
  src/qase/commons/util/__init__.py
44
+ src/qase/commons/util/api_host.py
44
45
  src/qase/commons/util/host_data.py
45
46
  src/qase/commons/util/token_masker.py
46
47
  src/qase/commons/validators/base.py