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

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

179 statements  

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 f'{get_service_module_name(client.meta.service_model)}.Waiter.{waiter_name}' 

68 ) 

69 

70 # Create the new waiter class 

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

72 

73 # Return an instance of the new waiter class. 

74 return documented_waiter_cls( 

75 waiter_name, single_waiter_config, operation_method 

76 ) 

77 

78 

79def is_valid_waiter_error(response): 

80 error = response.get('Error') 

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

82 return True 

83 return False 

84 

85 

86class NormalizedOperationMethod: 

87 def __init__(self, client_method): 

88 self._client_method = client_method 

89 

90 def __call__(self, **kwargs): 

91 try: 

92 return self._client_method(**kwargs) 

93 except ClientError as e: 

94 return e.response 

95 

96 

97class WaiterModel: 

98 SUPPORTED_VERSION = 2 

99 

100 def __init__(self, waiter_config): 

101 """ 

102 

103 Note that the WaiterModel takes ownership of the waiter_config. 

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

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

106 the WaiterModel. 

107 

108 :type waiter_config: dict 

109 :param waiter_config: The loaded waiter config 

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

111 obtained from a botocore Loader object as well. 

112 

113 """ 

114 self._waiter_config = waiter_config['waiters'] 

115 

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

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

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

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

120 self._verify_supported_version(version) 

121 self.version = version 

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

123 

124 def _verify_supported_version(self, version): 

125 if version != self.SUPPORTED_VERSION: 

126 raise WaiterConfigError( 

127 error_msg=( 

128 "Unsupported waiter version, supported version " 

129 f"must be: {self.SUPPORTED_VERSION}, but version " 

130 f"of waiter config is: {version}" 

131 ) 

132 ) 

133 

134 def get_waiter(self, waiter_name): 

135 try: 

136 single_waiter_config = self._waiter_config[waiter_name] 

137 except KeyError: 

138 raise ValueError(f"Waiter does not exist: {waiter_name}") 

139 return SingleWaiterConfig(single_waiter_config) 

140 

141 

142class SingleWaiterConfig: 

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

144 

145 A single waiter is considered the configuration for a single 

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

147 

148 """ 

149 

150 def __init__(self, single_waiter_config): 

151 self._config = single_waiter_config 

152 

153 # These attributes are part of the public API. 

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

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

156 self.operation = single_waiter_config['operation'] 

157 self.delay = single_waiter_config['delay'] 

158 self.max_attempts = single_waiter_config['maxAttempts'] 

159 

160 @property 

161 def acceptors(self): 

162 acceptors = [] 

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

164 acceptor = AcceptorConfig(acceptor_config) 

165 acceptors.append(acceptor) 

166 return acceptors 

167 

168 

169class AcceptorConfig: 

170 def __init__(self, config): 

171 self.state = config['state'] 

172 self.matcher = config['matcher'] 

173 self.expected = config['expected'] 

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

175 self.matcher_func = self._create_matcher_func() 

176 

177 @property 

178 def explanation(self): 

179 if self.matcher == 'path': 

180 return f'For expression "{self.argument}" we matched expected path: "{self.expected}"' 

181 elif self.matcher == 'pathAll': 

182 return ( 

183 f'For expression "{self.argument}" all members matched ' 

184 f'expected path: "{self.expected}"' 

185 ) 

186 elif self.matcher == 'pathAny': 

187 return ( 

188 f'For expression "{self.argument}" we matched expected ' 

189 f'path: "{self.expected}" at least once' 

190 ) 

191 elif self.matcher == 'status': 

192 return f'Matched expected HTTP status code: {self.expected}' 

193 elif self.matcher == 'error': 

194 return f'Matched expected service error code: {self.expected}' 

195 else: 

196 return f'No explanation for unknown waiter type: "{self.matcher}"' 

197 

198 def _create_matcher_func(self): 

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

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

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

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

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

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

205 # out to separate methods or even objects. 

206 

207 if self.matcher == 'path': 

208 return self._create_path_matcher() 

209 elif self.matcher == 'pathAll': 

210 return self._create_path_all_matcher() 

211 elif self.matcher == 'pathAny': 

212 return self._create_path_any_matcher() 

213 elif self.matcher == 'status': 

214 return self._create_status_matcher() 

215 elif self.matcher == 'error': 

216 return self._create_error_matcher() 

217 else: 

218 raise WaiterConfigError( 

219 error_msg=f"Unknown acceptor: {self.matcher}" 

220 ) 

221 

222 def _create_path_matcher(self): 

223 expression = jmespath.compile(self.argument) 

224 expected = self.expected 

225 

226 def acceptor_matches(response): 

227 if is_valid_waiter_error(response): 

228 return 

229 return expression.search(response) == expected 

230 

231 return acceptor_matches 

232 

233 def _create_path_all_matcher(self): 

234 expression = jmespath.compile(self.argument) 

235 expected = self.expected 

236 

237 def acceptor_matches(response): 

238 if is_valid_waiter_error(response): 

239 return 

240 result = expression.search(response) 

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

242 # pathAll matcher must result in a list. 

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

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

245 # acceptor match. 

246 return False 

247 for element in result: 

248 if element != expected: 

249 return False 

250 return True 

251 

252 return acceptor_matches 

253 

254 def _create_path_any_matcher(self): 

255 expression = jmespath.compile(self.argument) 

256 expected = self.expected 

257 

258 def acceptor_matches(response): 

259 if is_valid_waiter_error(response): 

260 return 

261 result = expression.search(response) 

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

263 # pathAny matcher must result in a list. 

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

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

266 # acceptor match. 

267 return False 

268 for element in result: 

269 if element == expected: 

270 return True 

271 return False 

272 

273 return acceptor_matches 

274 

275 def _create_status_matcher(self): 

276 expected = self.expected 

277 

278 def acceptor_matches(response): 

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

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

281 # a ResponseMetadata.HTTPStatusCode. 

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

283 'HTTPStatusCode' 

284 ) 

285 return status_code == expected 

286 

287 return acceptor_matches 

288 

289 def _create_error_matcher(self): 

290 expected = self.expected 

291 

292 def acceptor_matches(response): 

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

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

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

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

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

298 # "ResponseMetadata" key. 

299 # When expected is True, accept any error code. 

300 # When expected is False, check if any errors were encountered. 

301 # Otherwise, check for a specific AWS error code. 

302 if expected is True: 

303 return "Error" in response and "Code" in response["Error"] 

304 elif expected is False: 

305 return "Error" not in response 

306 else: 

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

308 

309 return acceptor_matches 

310 

311 

312class Waiter: 

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

314 """ 

315 

316 :type name: string 

317 :param name: The name of the waiter 

318 

319 :type config: botocore.waiter.SingleWaiterConfig 

320 :param config: The configuration for the waiter. 

321 

322 :type operation_method: callable 

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

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

325 a method from a botocore client. 

326 

327 """ 

328 self._operation_method = operation_method 

329 # The two attributes are exposed to allow for introspection 

330 # and documentation. 

331 self.name = name 

332 self.config = config 

333 

334 def wait(self, **kwargs): 

335 acceptors = list(self.config.acceptors) 

336 current_state = 'waiting' 

337 # pop the invocation specific config 

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

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

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

341 last_matched_acceptor = None 

342 num_attempts = 0 

343 

344 while True: 

345 response = self._operation_method(**kwargs) 

346 num_attempts += 1 

347 for acceptor in acceptors: 

348 if acceptor.matcher_func(response): 

349 last_matched_acceptor = acceptor 

350 current_state = acceptor.state 

351 break 

352 else: 

353 # If none of the acceptors matched, we should 

354 # transition to the failure state if an error 

355 # response was received. 

356 if is_valid_waiter_error(response): 

357 # Transition to a failure state, which we 

358 # can just handle here by raising an exception. 

359 raise WaiterError( 

360 name=self.name, 

361 reason='An error occurred ({}): {}'.format( 

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

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

364 ), 

365 last_response=response, 

366 ) 

367 if current_state == 'success': 

368 logger.debug( 

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

370 ) 

371 return 

372 if current_state == 'failure': 

373 reason = f'Waiter encountered a terminal failure state: {acceptor.explanation}' 

374 raise WaiterError( 

375 name=self.name, 

376 reason=reason, 

377 last_response=response, 

378 ) 

379 if num_attempts >= max_attempts: 

380 if last_matched_acceptor is None: 

381 reason = 'Max attempts exceeded' 

382 else: 

383 reason = ( 

384 f'Max attempts exceeded. Previously accepted state: ' 

385 f'{acceptor.explanation}' 

386 ) 

387 raise WaiterError( 

388 name=self.name, 

389 reason=reason, 

390 last_response=response, 

391 ) 

392 time.sleep(sleep_amount)