Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/site-packages/botocore/waiter.py: 20%

175 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-12-08 06:51 +0000

1# Copyright 2012-2014 Amazon.com, Inc. or its affiliates. All Rights Reserved. 

2# 

3# Licensed under the Apache License, Version 2.0 (the "License"). You 

4# may not use this file except in compliance with the License. A copy of 

5# the License is located at 

6# 

7# http://aws.amazon.com/apache2.0/ 

8# 

9# or in the "license" file accompanying this file. This file is 

10# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 

11# ANY KIND, either express or implied. See the License for the specific 

12# language governing permissions and limitations under the License. 

13import logging 

14import time 

15 

16import jmespath 

17 

18from botocore.docs.docstring import WaiterDocstring 

19from botocore.utils import get_service_module_name 

20 

21from . import xform_name 

22from .exceptions import ClientError, WaiterConfigError, WaiterError 

23 

24logger = logging.getLogger(__name__) 

25 

26 

27def create_waiter_with_client(waiter_name, waiter_model, client): 

28 """ 

29 

30 :type waiter_name: str 

31 :param waiter_name: The name of the waiter. The name should match 

32 the name (including the casing) of the key name in the waiter 

33 model file (typically this is CamelCasing). 

34 

35 :type waiter_model: botocore.waiter.WaiterModel 

36 :param waiter_model: The model for the waiter configuration. 

37 

38 :type client: botocore.client.BaseClient 

39 :param client: The botocore client associated with the service. 

40 

41 :rtype: botocore.waiter.Waiter 

42 :return: The waiter object. 

43 

44 """ 

45 single_waiter_config = waiter_model.get_waiter(waiter_name) 

46 operation_name = xform_name(single_waiter_config.operation) 

47 operation_method = NormalizedOperationMethod( 

48 getattr(client, operation_name) 

49 ) 

50 

51 # Create a new wait method that will serve as a proxy to the underlying 

52 # Waiter.wait method. This is needed to attach a docstring to the 

53 # method. 

54 def wait(self, **kwargs): 

55 Waiter.wait(self, **kwargs) 

56 

57 wait.__doc__ = WaiterDocstring( 

58 waiter_name=waiter_name, 

59 event_emitter=client.meta.events, 

60 service_model=client.meta.service_model, 

61 service_waiter_model=waiter_model, 

62 include_signature=False, 

63 ) 

64 

65 # Rename the waiter class based on the type of waiter. 

66 waiter_class_name = str( 

67 '%s.Waiter.%s' 

68 % (get_service_module_name(client.meta.service_model), waiter_name) 

69 ) 

70 

71 # Create the new waiter class 

72 documented_waiter_cls = type(waiter_class_name, (Waiter,), {'wait': wait}) 

73 

74 # Return an instance of the new waiter class. 

75 return documented_waiter_cls( 

76 waiter_name, single_waiter_config, operation_method 

77 ) 

78 

79 

80def is_valid_waiter_error(response): 

81 error = response.get('Error') 

82 if isinstance(error, dict) and 'Code' in error: 

83 return True 

84 return False 

85 

86 

87class NormalizedOperationMethod: 

88 def __init__(self, client_method): 

89 self._client_method = client_method 

90 

91 def __call__(self, **kwargs): 

92 try: 

93 return self._client_method(**kwargs) 

94 except ClientError as e: 

95 return e.response 

96 

97 

98class WaiterModel: 

99 SUPPORTED_VERSION = 2 

100 

101 def __init__(self, waiter_config): 

102 """ 

103 

104 Note that the WaiterModel takes ownership of the waiter_config. 

105 It may or may not mutate the waiter_config. If this is a concern, 

106 it is best to make a copy of the waiter config before passing it to 

107 the WaiterModel. 

108 

109 :type waiter_config: dict 

110 :param waiter_config: The loaded waiter config 

111 from the <service>*.waiters.json file. This can be 

112 obtained from a botocore Loader object as well. 

113 

114 """ 

115 self._waiter_config = waiter_config['waiters'] 

116 

117 # These are part of the public API. Changing these 

118 # will result in having to update the consuming code, 

119 # so don't change unless you really need to. 

120 version = waiter_config.get('version', 'unknown') 

121 self._verify_supported_version(version) 

122 self.version = version 

123 self.waiter_names = list(sorted(waiter_config['waiters'].keys())) 

124 

125 def _verify_supported_version(self, version): 

126 if version != self.SUPPORTED_VERSION: 

127 raise WaiterConfigError( 

128 error_msg=( 

129 "Unsupported waiter version, supported version " 

130 "must be: %s, but version of waiter config " 

131 "is: %s" % (self.SUPPORTED_VERSION, version) 

132 ) 

133 ) 

134 

135 def get_waiter(self, waiter_name): 

136 try: 

137 single_waiter_config = self._waiter_config[waiter_name] 

138 except KeyError: 

139 raise ValueError("Waiter does not exist: %s" % waiter_name) 

140 return SingleWaiterConfig(single_waiter_config) 

141 

142 

143class SingleWaiterConfig: 

144 """Represents the waiter configuration for a single waiter. 

145 

146 A single waiter is considered the configuration for a single 

147 value associated with a named waiter (i.e TableExists). 

148 

149 """ 

150 

151 def __init__(self, single_waiter_config): 

152 self._config = single_waiter_config 

153 

154 # These attributes are part of the public API. 

155 self.description = single_waiter_config.get('description', '') 

156 # Per the spec, these three fields are required. 

157 self.operation = single_waiter_config['operation'] 

158 self.delay = single_waiter_config['delay'] 

159 self.max_attempts = single_waiter_config['maxAttempts'] 

160 

161 @property 

162 def acceptors(self): 

163 acceptors = [] 

164 for acceptor_config in self._config['acceptors']: 

165 acceptor = AcceptorConfig(acceptor_config) 

166 acceptors.append(acceptor) 

167 return acceptors 

168 

169 

170class AcceptorConfig: 

171 def __init__(self, config): 

172 self.state = config['state'] 

173 self.matcher = config['matcher'] 

174 self.expected = config['expected'] 

175 self.argument = config.get('argument') 

176 self.matcher_func = self._create_matcher_func() 

177 

178 @property 

179 def explanation(self): 

180 if self.matcher == 'path': 

181 return 'For expression "{}" we matched expected path: "{}"'.format( 

182 self.argument, 

183 self.expected, 

184 ) 

185 elif self.matcher == 'pathAll': 

186 return ( 

187 'For expression "%s" all members matched excepted path: "%s"' 

188 % (self.argument, self.expected) 

189 ) 

190 elif self.matcher == 'pathAny': 

191 return ( 

192 'For expression "%s" we matched expected path: "%s" at least once' 

193 % (self.argument, self.expected) 

194 ) 

195 elif self.matcher == 'status': 

196 return 'Matched expected HTTP status code: %s' % self.expected 

197 elif self.matcher == 'error': 

198 return 'Matched expected service error code: %s' % self.expected 

199 else: 

200 return ( 

201 'No explanation for unknown waiter type: "%s"' % self.matcher 

202 ) 

203 

204 def _create_matcher_func(self): 

205 # An acceptor function is a callable that takes a single value. The 

206 # parsed AWS response. Note that the parsed error response is also 

207 # provided in the case of errors, so it's entirely possible to 

208 # handle all the available matcher capabilities in the future. 

209 # There's only three supported matchers, so for now, this is all 

210 # contained to a single method. If this grows, we can expand this 

211 # out to separate methods or even objects. 

212 

213 if self.matcher == 'path': 

214 return self._create_path_matcher() 

215 elif self.matcher == 'pathAll': 

216 return self._create_path_all_matcher() 

217 elif self.matcher == 'pathAny': 

218 return self._create_path_any_matcher() 

219 elif self.matcher == 'status': 

220 return self._create_status_matcher() 

221 elif self.matcher == 'error': 

222 return self._create_error_matcher() 

223 else: 

224 raise WaiterConfigError( 

225 error_msg="Unknown acceptor: %s" % self.matcher 

226 ) 

227 

228 def _create_path_matcher(self): 

229 expression = jmespath.compile(self.argument) 

230 expected = self.expected 

231 

232 def acceptor_matches(response): 

233 if is_valid_waiter_error(response): 

234 return 

235 return expression.search(response) == expected 

236 

237 return acceptor_matches 

238 

239 def _create_path_all_matcher(self): 

240 expression = jmespath.compile(self.argument) 

241 expected = self.expected 

242 

243 def acceptor_matches(response): 

244 if is_valid_waiter_error(response): 

245 return 

246 result = expression.search(response) 

247 if not isinstance(result, list) or not result: 

248 # pathAll matcher must result in a list. 

249 # Also we require at least one element in the list, 

250 # that is, an empty list should not result in this 

251 # acceptor match. 

252 return False 

253 for element in result: 

254 if element != expected: 

255 return False 

256 return True 

257 

258 return acceptor_matches 

259 

260 def _create_path_any_matcher(self): 

261 expression = jmespath.compile(self.argument) 

262 expected = self.expected 

263 

264 def acceptor_matches(response): 

265 if is_valid_waiter_error(response): 

266 return 

267 result = expression.search(response) 

268 if not isinstance(result, list) or not result: 

269 # pathAny matcher must result in a list. 

270 # Also we require at least one element in the list, 

271 # that is, an empty list should not result in this 

272 # acceptor match. 

273 return False 

274 for element in result: 

275 if element == expected: 

276 return True 

277 return False 

278 

279 return acceptor_matches 

280 

281 def _create_status_matcher(self): 

282 expected = self.expected 

283 

284 def acceptor_matches(response): 

285 # We don't have any requirements on the expected incoming data 

286 # other than it is a dict, so we don't assume there's 

287 # a ResponseMetadata.HTTPStatusCode. 

288 status_code = response.get('ResponseMetadata', {}).get( 

289 'HTTPStatusCode' 

290 ) 

291 return status_code == expected 

292 

293 return acceptor_matches 

294 

295 def _create_error_matcher(self): 

296 expected = self.expected 

297 

298 def acceptor_matches(response): 

299 # When the client encounters an error, it will normally raise 

300 # an exception. However, the waiter implementation will catch 

301 # this exception, and instead send us the parsed error 

302 # response. So response is still a dictionary, and in the case 

303 # of an error response will contain the "Error" and 

304 # "ResponseMetadata" key. 

305 return response.get("Error", {}).get("Code", "") == expected 

306 

307 return acceptor_matches 

308 

309 

310class Waiter: 

311 def __init__(self, name, config, operation_method): 

312 """ 

313 

314 :type name: string 

315 :param name: The name of the waiter 

316 

317 :type config: botocore.waiter.SingleWaiterConfig 

318 :param config: The configuration for the waiter. 

319 

320 :type operation_method: callable 

321 :param operation_method: A callable that accepts **kwargs 

322 and returns a response. For example, this can be 

323 a method from a botocore client. 

324 

325 """ 

326 self._operation_method = operation_method 

327 # The two attributes are exposed to allow for introspection 

328 # and documentation. 

329 self.name = name 

330 self.config = config 

331 

332 def wait(self, **kwargs): 

333 acceptors = list(self.config.acceptors) 

334 current_state = 'waiting' 

335 # pop the invocation specific config 

336 config = kwargs.pop('WaiterConfig', {}) 

337 sleep_amount = config.get('Delay', self.config.delay) 

338 max_attempts = config.get('MaxAttempts', self.config.max_attempts) 

339 last_matched_acceptor = None 

340 num_attempts = 0 

341 

342 while True: 

343 response = self._operation_method(**kwargs) 

344 num_attempts += 1 

345 for acceptor in acceptors: 

346 if acceptor.matcher_func(response): 

347 last_matched_acceptor = acceptor 

348 current_state = acceptor.state 

349 break 

350 else: 

351 # If none of the acceptors matched, we should 

352 # transition to the failure state if an error 

353 # response was received. 

354 if is_valid_waiter_error(response): 

355 # Transition to a failure state, which we 

356 # can just handle here by raising an exception. 

357 raise WaiterError( 

358 name=self.name, 

359 reason='An error occurred (%s): %s' 

360 % ( 

361 response['Error'].get('Code', 'Unknown'), 

362 response['Error'].get('Message', 'Unknown'), 

363 ), 

364 last_response=response, 

365 ) 

366 if current_state == 'success': 

367 logger.debug( 

368 "Waiting complete, waiter matched the " "success state." 

369 ) 

370 return 

371 if current_state == 'failure': 

372 reason = 'Waiter encountered a terminal failure state: %s' % ( 

373 acceptor.explanation 

374 ) 

375 raise WaiterError( 

376 name=self.name, 

377 reason=reason, 

378 last_response=response, 

379 ) 

380 if num_attempts >= max_attempts: 

381 if last_matched_acceptor is None: 

382 reason = 'Max attempts exceeded' 

383 else: 

384 reason = ( 

385 'Max attempts exceeded. Previously accepted state: %s' 

386 % (acceptor.explanation) 

387 ) 

388 raise WaiterError( 

389 name=self.name, 

390 reason=reason, 

391 last_response=response, 

392 ) 

393 time.sleep(sleep_amount)