Coverage for src/pytest_xray/helper.py: 87.5%

166 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-16 14:00 +0000

1import datetime as dt 

2import enum 

3import os 

4import re 

5from os import environ 

6from typing import Any, Optional, Union 

7 

8from pytest_xray import constant 

9from pytest_xray.constant import ( 

10 DATETIME_FORMAT, 

11 ENV_XRAY_API_BASE_URL, 

12 ENV_XRAY_API_KEY, 

13 ENV_XRAY_API_PASSWORD, 

14 ENV_XRAY_API_USER, 

15 ENV_XRAY_API_VERIFY_SSL, 

16 ENV_XRAY_CLIENT_ID, 

17 ENV_XRAY_CLIENT_SECRET, 

18) 

19from pytest_xray.exceptions import XrayError 

20 

21DEFAULT_SUMMARY_DESCRIPTION: str = 'Execution of automated tests' 

22 

23 

24class Status(str, enum.Enum): 

25 TODO = 'TODO' 

26 EXECUTING = 'EXECUTING' 

27 PENDING = 'PENDING' 

28 PASS = 'PASS' 

29 FAIL = 'FAIL' 

30 ABORTED = 'ABORTED' 

31 BLOCKED = 'BLOCKED' 

32 

33 

34# This is the hierarchy of the Status, from bottom to top. 

35# When merging two statuses, the highest will be picked. 

36# For example, a PASS and a FAIL will result in a FAIL, 

37# A TODO and an ABORTED in an ABORTED, A TODO and a PASS in a TODO. 

38STATUS_HIERARCHY: list[Status] = [ 

39 Status.PASS, 

40 Status.TODO, 

41 Status.EXECUTING, 

42 Status.PENDING, 

43 Status.FAIL, 

44 Status.ABORTED, 

45 Status.BLOCKED, 

46] 

47 

48# Maps the Status from the internal Status enum to the string representations 

49# requested by either the Cloud Jira, or the on-site Jira 

50STATUS_STR_MAPPER_CLOUD: dict[Status, str] = { 

51 Status.TODO: 'TODO', 

52 Status.EXECUTING: 'EXECUTING', 

53 Status.PENDING: 'PENDING', 

54 Status.PASS: 'PASSED', 

55 Status.FAIL: 'FAILED', 

56 Status.ABORTED: 'ABORTED', 

57 Status.BLOCKED: 'BLOCKED', 

58} 

59 

60# On-site jira uses the enum strings directly 

61STATUS_STR_MAPPER_JIRA: dict[Status, str] = {x: x.value for x in Status} 

62 

63 

64class TestCase: 

65 __test__ = False 

66 

67 def __init__( 

68 self, 

69 test_key: str, 

70 status: Status, 

71 comment: Optional[str] = None, 

72 status_str_mapper: Optional[dict[Status, str]] = None, 

73 evidences: Optional[list[dict[str, str]]] = None, 

74 defects: Optional[list[str]] = None, 

75 ) -> None: 

76 self.test_key = test_key 

77 self.status = status 

78 self.comment = comment or '' 

79 self.status_str_mapper = status_str_mapper or STATUS_STR_MAPPER_JIRA 

80 # One report can map to multiple Test keys; each aggregate owns its lists. 

81 self.evidences = list(evidences or []) 

82 self.defects = list(defects or []) 

83 

84 def merge(self, other: 'TestCase') -> None: 

85 """ 

86 Merges this test case with other, in order to obtain 

87 a combined result. Comments will be just appended one after the other. 

88 status will be merged according to a priority list. 

89 Merge is only possible if the two tests have the same test_key 

90 """ 

91 

92 if self.test_key != other.test_key: 

93 raise ValueError(f'Cannot merge test with different test keys: {self.test_key} {other.test_key}') 

94 

95 if self.comment == '': 

96 if other.comment != '': 

97 self.comment = other.comment 

98 else: 

99 if other.comment != '': 

100 self.comment += '\n' + '-' * 80 + '\n' 

101 self.comment += other.comment 

102 

103 self.status = _merge_status(self.status, other.status) 

104 

105 for defect in other.defects: 

106 if defect not in self.defects: 

107 self.defects.append(defect) 

108 

109 for evidence in other.evidences: 

110 self.evidences.append(evidence) 

111 

112 def as_dict(self) -> dict[str, Any]: 

113 data: dict[str, Any] = dict( 

114 testKey=self.test_key, 

115 status=self.status_str_mapper[self.status], 

116 ) 

117 if self.comment != '': 

118 data['comment'] = '{noformat:borderWidth=0px|bgColor=transparent}' + self.comment + '{noformat}' 

119 if self.evidences: 

120 data['evidences'] = self.evidences 

121 if self.defects: 

122 data['defects'] = self.defects 

123 return data 

124 

125 

126class TestExecution: 

127 __test__ = False 

128 

129 def __init__( 

130 self, 

131 test_execution_key: Optional[str] = None, 

132 test_plan_key: Optional[str] = None, 

133 user: Optional[str] = None, 

134 revision: Optional[str] = None, 

135 tests: Optional[list[TestCase]] = None, 

136 test_environments: Optional[list[str]] = None, 

137 fix_version: Optional[str] = None, 

138 summary: Optional[str] = None, 

139 description: Optional[str] = None, 

140 ): 

141 self.test_execution_key = test_execution_key 

142 self.test_plan_key: str = test_plan_key or '' 

143 self.user = user or '' 

144 self.revision = revision or _from_environ_or_none(constant.ENV_TEST_EXECUTION_REVISION) 

145 self.start_date = dt.datetime.now(tz=dt.timezone.utc) 

146 self.finish_date = dt.datetime.now(tz=dt.timezone.utc) 

147 self.tests = tests or [] 

148 self.test_environments = test_environments or _from_environ( 

149 constant.ENV_TEST_EXECUTION_TEST_ENVIRONMENTS, constant.ENV_MULTI_VALUE_SPLIT_PATTERN 

150 ) 

151 self.fix_version = fix_version or _first_from_environ(constant.ENV_TEST_EXECUTION_FIX_VERSION) 

152 self.summary = self._get_summery(summary) 

153 self.description = description or _from_environ_or_none(constant.ENV_TEST_EXECUTION_DESC) 

154 

155 def _get_summery(self, summary: Union[str, None]) -> Union[str, None]: 

156 summary = summary or _from_environ_or_none(constant.ENV_TEST_EXECUTION_SUMMARY) 

157 if summary is not None: 

158 return summary 

159 # do not set `summary` if uploading to existing test execution 

160 if self.test_execution_key is None: 

161 return DEFAULT_SUMMARY_DESCRIPTION 

162 return None 

163 

164 def append(self, test: Union[dict, TestCase]) -> None: 

165 if not isinstance(test, TestCase): 165 ↛ 166line 165 didn't jump to line 166 because the condition on line 165 was never true

166 test = TestCase(**test) 

167 self.tests.append(test) 

168 

169 def find_test_case(self, test_key: str) -> TestCase: 

170 """ 

171 Searches a stored test case by identifier. 

172 If not found, raises KeyError 

173 """ 

174 # Linear search, but who cares really of performance here? 

175 

176 for test in self.tests: 

177 if test.test_key == test_key: 

178 return test 

179 

180 raise KeyError(test_key) 

181 

182 def as_dict(self) -> dict[str, Any]: 

183 """Return test execution result as dictionary.""" 

184 tests = [test.as_dict() for test in self.tests] 

185 info: dict[str, Any] = dict( 

186 startDate=self.start_date.strftime(DATETIME_FORMAT), 

187 finishDate=self.finish_date.strftime(DATETIME_FORMAT), # type: ignore 

188 ) 

189 

190 if self.fix_version: 

191 info['version'] = self.fix_version 

192 

193 if self.test_environments and len(self.test_environments) > 0: 

194 info['testEnvironments'] = self.test_environments 

195 

196 if self.summary is not None: 

197 info['summary'] = self.summary 

198 

199 if self.description is not None: 

200 info['description'] = self.description 

201 

202 if self.revision is not None: 202 ↛ 203line 202 didn't jump to line 203 because the condition on line 202 was never true

203 info['revision'] = self.revision 

204 

205 data: dict[str, Any] = dict(info=info, tests=tests) 

206 if self.test_plan_key: 

207 info['testPlanKey'] = self.test_plan_key 

208 if self.test_execution_key is not None: 

209 data['testExecutionKey'] = self.test_execution_key 

210 return data 

211 

212 

213def get_base_options() -> dict[str, Any]: 

214 """Return authentication configuration from environment variables.""" 

215 options = {} 

216 try: 

217 base_url = environ[ENV_XRAY_API_BASE_URL] 

218 except KeyError: 

219 raise XrayError(f'pytest-jira-xray plugin requires environment variable: {ENV_XRAY_API_BASE_URL}') from None 

220 

221 verify = os.environ.get(ENV_XRAY_API_VERIFY_SSL, 'True') 

222 

223 if verify.upper() == 'TRUE': 223 ↛ 225line 223 didn't jump to line 225 because the condition on line 223 was always true

224 verify = True # type: ignore 

225 elif verify.upper() == 'FALSE': 

226 verify = False # type: ignore 

227 else: 

228 if not os.path.exists(verify): 

229 raise XrayError(f'Cannot find certificate file "{verify}"') 

230 

231 options['VERIFY'] = verify 

232 options['BASE_URL'] = base_url 

233 return options 

234 

235 

236def get_basic_auth() -> dict[str, Any]: 

237 """Return basic authentication setup with username and password.""" 

238 options = get_base_options() 

239 try: 

240 user = environ[ENV_XRAY_API_USER] 

241 password = environ[ENV_XRAY_API_PASSWORD] 

242 except KeyError: 

243 raise XrayError( 

244 f'Basic authentication requires environment variables: {ENV_XRAY_API_USER}, {ENV_XRAY_API_PASSWORD}' 

245 ) from None 

246 

247 options['USER'] = user 

248 options['PASSWORD'] = password 

249 return options 

250 

251 

252def get_bearer_auth() -> dict[str, Any]: 

253 """Return bearer authentication setup with Client ID and a Client Secret.""" 

254 options = get_base_options() 

255 try: 

256 client_id = environ[ENV_XRAY_CLIENT_ID] 

257 client_secret = environ[ENV_XRAY_CLIENT_SECRET] 

258 except KeyError: 

259 raise XrayError( 

260 f'Bearer authentication requires environment variables: {ENV_XRAY_CLIENT_ID}, {ENV_XRAY_CLIENT_SECRET}' 

261 ) from None 

262 

263 options['CLIENT_ID'] = client_id 

264 options['CLIENT_SECRET'] = client_secret 

265 return options 

266 

267 

268def get_api_key_auth() -> dict[str, Any]: 

269 """Return personal access token authentication.""" 

270 options = get_base_options() 

271 try: 

272 api_key = environ[ENV_XRAY_API_KEY] 

273 except KeyError: 

274 raise XrayError(f'API Key authentication requires environment variable: {ENV_XRAY_API_KEY}') from None 

275 

276 options['API_KEY'] = api_key 

277 return options 

278 

279 

280def _from_environ_or_none(name: str) -> Optional[str]: 

281 if name in environ: 281 ↛ 282line 281 didn't jump to line 282 because the condition on line 281 was never true

282 val = environ[name].strip() 

283 if len(val) == 0: 

284 return None 

285 else: 

286 return None 

287 return val 

288 

289 

290def _first_from_environ(name: str, separator: Optional[str] = None) -> Optional[str]: 

291 return next(iter(_from_environ(name, separator)), None) 

292 

293 

294def _from_environ(name: str, separator: Optional[str] = None) -> list[str]: 

295 if name not in environ: 

296 return [] 

297 

298 param = environ[name] 

299 

300 if separator: 

301 source = re.split(separator, param) 

302 else: 

303 source = [param] 

304 

305 # Return stripped non empty values 

306 return list(filter(lambda x: len(x) > 0, map(lambda x: x.strip(), source))) 

307 

308 

309def _merge_status(status_1: Status, status_2: Status) -> Status: 

310 """Merges the status of two tests.""" 

311 

312 return STATUS_HIERARCHY[max(STATUS_HIERARCHY.index(status_1), STATUS_HIERARCHY.index(status_2))]