blob: 7dab156c7d1d7397b9517bcf4ac194b3f0f4092a [file] [log] [blame]
Illyoung Choia9d2c2c2019-07-12 13:29:42 -07001#!/usr/bin/env python3
2
3# Copyright 2019-present Open Networking Foundation
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16
17"""
18Workflow Manager
19
20This module implements Workflow Manager interface
21"""
22
23import json
24import socketio
25
26from .countdown_latch import CountDownLatch
27from .utils import get_noop_logger, gen_id, gen_seq_id
28from .errors import ClientRPCError, ClientInputError, ClientResponseError
29
30WAIT_TIMEOUT = 10 # 10 seconds
31
32# controller -> manager
33GREETING = 'cord.workflow.ctlsvc.greeting'
34WORKFLOW_KICKSTART = 'cord.workflow.ctlsvc.workflow.kickstart'
Illyoung Choib9ab43f2019-07-29 14:13:25 -070035WORKFLOW_CHECK_STATUS = 'cord.workflow.ctlsvc.workflow.check.status'
36WORKFLOW_CHECK_STATUS_BULK = 'cord.workflow.ctlsvc.workflow.check.status_bulk'
Illyoung Choia9d2c2c2019-07-12 13:29:42 -070037
38# manager -> controller -> manager
39WORKFLOW_REGISTER = 'cord.workflow.ctlsvc.workflow.register'
40WORKFLOW_REGISTER_ESSENCE = 'cord.workflow.ctlsvc.workflow.register_essence'
41WORKFLOW_LIST = 'cord.workflow.ctlsvc.workflow.list'
42WORKFLOW_LIST_RUN = 'cord.workflow.ctlsvc.workflow.run.list'
43WORKFLOW_CHECK = 'cord.workflow.ctlsvc.workflow.check'
44WORKFLOW_REMOVE = 'cord.workflow.ctlsvc.workflow.remove'
45WORKFLOW_REMOVE_RUN = 'cord.workflow.ctlsvc.workflow.run.remove'
Illyoung Choi4df34b72019-07-18 13:55:18 -070046WORKFLOW_REPORT_NEW_RUN = 'cord.workflow.ctlsvc.workflow.report_new_run'
Illyoung Choib9ab43f2019-07-29 14:13:25 -070047WORKFLOW_REPORT_RUN_STATUS = 'cord.workflow.ctlsvc.workflow.report_run_status'
48WORKFLOW_REPORT_RUN_STATUS_BULK = 'cord.workflow.ctlsvc.workflow.report_run_status_bulk'
Illyoung Choia9d2c2c2019-07-12 13:29:42 -070049
50
51class Manager(object):
52 def __init__(self, logger=None, name=None):
53 self.sio = socketio.Client()
54
55 if logger:
56 self.logger = logger
57 else:
58 self.logger = get_noop_logger()
59
60 if name:
61 self.name = name
62 else:
63 self.name = 'manager_%s' % gen_id()
64
65 self.req_id = gen_seq_id()
66
67 # set sio handlers
68 self.logger.debug('Setting event handlers to Socket.IO')
69 self.sio.on('connect', self.__on_sio_connect)
70 self.sio.on('disconnect', self.__on_sio_disconnect)
71 self.sio.on(WORKFLOW_KICKSTART, self.__on_kickstart_message)
Illyoung Choib9ab43f2019-07-29 14:13:25 -070072 self.sio.on(WORKFLOW_CHECK_STATUS, self.__on_check_status_message)
73 self.sio.on(WORKFLOW_CHECK_STATUS_BULK, self.__on_check_status_bulk_message)
Illyoung Choia9d2c2c2019-07-12 13:29:42 -070074 self.sio.on(GREETING, self.__on_greeting_message)
75 self.sio.on(WORKFLOW_REGISTER, self.__on_workflow_reg_message)
76 self.sio.on(WORKFLOW_REGISTER_ESSENCE, self.__on_workflow_reg_essence_message)
77 self.sio.on(WORKFLOW_LIST, self.__on_workflow_list_message)
78 self.sio.on(WORKFLOW_LIST_RUN, self.__on_workflow_list_run_message)
79 self.sio.on(WORKFLOW_CHECK, self.__on_workflow_check_message)
80 self.sio.on(WORKFLOW_REMOVE, self.__on_workflow_remove_message)
81 self.sio.on(WORKFLOW_REMOVE_RUN, self.__on_workflow_remove_run_message)
Illyoung Choi4df34b72019-07-18 13:55:18 -070082 self.sio.on(WORKFLOW_REPORT_NEW_RUN, self.__on_workflow_report_new_run_message)
Illyoung Choib9ab43f2019-07-29 14:13:25 -070083 self.sio.on(WORKFLOW_REPORT_RUN_STATUS, self.__on_workflow_report_run_status_message)
84 self.sio.on(WORKFLOW_REPORT_RUN_STATUS_BULK, self.__on_workflow_report_run_status_bulk_message)
Illyoung Choia9d2c2c2019-07-12 13:29:42 -070085
86 self.handlers = {
87 'connect': self.__noop_connect_handler,
88 'disconnect': self.__noop_disconnect_handler,
Illyoung Choi4df34b72019-07-18 13:55:18 -070089 'kickstart': self.__noop_kickstart_handler,
Illyoung Choib9ab43f2019-07-29 14:13:25 -070090 'check_status': self.__noop_check_status_handler,
91 'check_status_bulk': self.__noop_check_status_bulk_handler,
Illyoung Choia9d2c2c2019-07-12 13:29:42 -070092 }
93
94 # key is req_id
95 self.pending_requests = {}
96
97 def set_logger(self, logger):
98 self.logger = logger
99
100 def get_logger(self):
101 return self.logger
102
103 def __on_sio_connect(self):
104 self.logger.debug('connected to the server')
105 handler = self.handlers['connect']
106 if callable(handler):
107 handler()
108
109 def __noop_connect_handler(self):
110 self.logger.debug('no-op connect handler')
111
112 def __on_sio_disconnect(self):
113 self.logger.debug('disconnected from the server')
114 handler = self.handlers['disconnect']
115 if callable(handler):
116 handler()
117
118 def __noop_disconnect_handler(self):
119 self.logger.debug('no-op disconnect handler')
120
121 def __noop_kickstart_handler(self, workflow_id, workflow_run_id):
122 self.logger.debug('no-op kickstart handler')
123
Illyoung Choib9ab43f2019-07-29 14:13:25 -0700124 def __noop_check_status_handler(self, workflow_id, workflow_run_id):
125 self.logger.debug('no-op check-status handler')
126
127 def __noop_check_status_bulk_handler(self, requests):
128 self.logger.debug('no-op check-status-bulk handler')
Illyoung Choi4df34b72019-07-18 13:55:18 -0700129
Illyoung Choia9d2c2c2019-07-12 13:29:42 -0700130 def __get_next_req_id(self):
131 req_id = self.req_id
132 self.req_id += 1
133 return req_id
134
135 def __on_greeting_message(self, data):
136 self.logger.debug('received a gretting message from the server')
137
138 def __on_kickstart_message(self, data):
139 """
140 Handler for a kickstart event
141 REQ = {
142 'workflow_id': <workflow_id>,
143 'workflow_run_id': <workflow_run_id>
144 }
145 """
146 self.logger.info('received a kickstart message from the server')
147 workflow_id = data['workflow_id']
148 workflow_run_id = data['workflow_run_id']
149
150 self.logger.info(
151 'a kickstart message - workflow_id (%s), workflow_run_id (%s)' %
152 (workflow_id, workflow_run_id)
153 )
154 if workflow_id and workflow_run_id:
155 handler = self.handlers['kickstart']
156 if callable(handler):
157 self.logger.info('calling a kickstart handler - %s' % handler)
158 handler(workflow_id, workflow_run_id)
159
Illyoung Choib9ab43f2019-07-29 14:13:25 -0700160 def __on_check_status_message(self, data):
Illyoung Choi4df34b72019-07-18 13:55:18 -0700161 """
Illyoung Choib9ab43f2019-07-29 14:13:25 -0700162 Handler for a check-status event
Illyoung Choi4df34b72019-07-18 13:55:18 -0700163 REQ = {
164 'workflow_id': <workflow_id>,
165 'workflow_run_id': <workflow_run_id>
166 }
167 """
Illyoung Choib9ab43f2019-07-29 14:13:25 -0700168 self.logger.info('received a check-status message from the server')
Illyoung Choi4df34b72019-07-18 13:55:18 -0700169 workflow_id = data['workflow_id']
170 workflow_run_id = data['workflow_run_id']
171
172 self.logger.info(
Illyoung Choib9ab43f2019-07-29 14:13:25 -0700173 'a check-status message - workflow_id (%s), workflow_run_id (%s)' %
Illyoung Choi4df34b72019-07-18 13:55:18 -0700174 (workflow_id, workflow_run_id)
175 )
176 if workflow_id and workflow_run_id:
Illyoung Choib9ab43f2019-07-29 14:13:25 -0700177 handler = self.handlers['check_status']
Illyoung Choi4df34b72019-07-18 13:55:18 -0700178 if callable(handler):
Illyoung Choib9ab43f2019-07-29 14:13:25 -0700179 self.logger.info('calling a check-status handler - %s' % handler)
Illyoung Choi4df34b72019-07-18 13:55:18 -0700180 handler(workflow_id, workflow_run_id)
181
Illyoung Choib9ab43f2019-07-29 14:13:25 -0700182 def __on_check_status_bulk_message(self, data):
183 """
184 Handler for a check-status-bulk event
185 REQ = [{
186 'workflow_id': <workflow_id>,
187 'workflow_run_id': <workflow_run_id>
188 }, ...]
189 """
190 self.logger.info('received a check-status-bulk message from the server')
191 if data:
192 handler = self.handlers['check_status_bulk']
193 if callable(handler):
194 self.logger.info('calling a check-status handler - %s' % handler)
195 handler(data)
196
Illyoung Choia9d2c2c2019-07-12 13:29:42 -0700197 def __on_workflow_reg_message(self, data):
198 self.__on_response(WORKFLOW_REGISTER, data)
199
200 def __on_workflow_reg_essence_message(self, data):
201 self.__on_response(WORKFLOW_REGISTER_ESSENCE, data)
202
203 def __on_workflow_list_message(self, data):
204 self.__on_response(WORKFLOW_LIST, data)
205
206 def __on_workflow_list_run_message(self, data):
207 self.__on_response(WORKFLOW_LIST_RUN, data)
208
209 def __on_workflow_check_message(self, data):
210 self.__on_response(WORKFLOW_CHECK, data)
211
212 def __on_workflow_remove_message(self, data):
213 self.__on_response(WORKFLOW_REMOVE, data)
214
215 def __on_workflow_remove_run_message(self, data):
216 self.__on_response(WORKFLOW_REMOVE_RUN, data)
217
Illyoung Choi4df34b72019-07-18 13:55:18 -0700218 def __on_workflow_report_new_run_message(self, data):
219 self.__on_response(WORKFLOW_REPORT_NEW_RUN, data)
220
Illyoung Choib9ab43f2019-07-29 14:13:25 -0700221 def __on_workflow_report_run_status_message(self, data):
222 self.__on_response(WORKFLOW_REPORT_RUN_STATUS, data)
223
224 def __on_workflow_report_run_status_bulk_message(self, data):
225 self.__on_response(WORKFLOW_REPORT_RUN_STATUS_BULK, data)
Illyoung Choia9d2c2c2019-07-12 13:29:42 -0700226
227 def __check_pending_request(self, req_id):
228 """
229 Check a pending request
230 """
231 if req_id in self.pending_requests:
232 return True
233 return False
234
235 def __put_pending_request(self, api, params):
236 """
237 Put a pending request to a queue
238 """
239 req_id = self.__get_next_req_id()
240 latch = CountDownLatch()
241 params['req_id'] = req_id # inject req_id
242 self.sio.emit(api, params)
243 self.pending_requests[req_id] = {
244 'req_id': req_id,
245 'latch': latch,
246 'api': api,
247 'params': params,
248 'result': None
249 }
250 return req_id
251
252 def __wait_response(self, req_id):
253 """
254 Wait for completion of a request
255 """
256 if req_id in self.pending_requests:
257 req = self.pending_requests[req_id]
258 # python v 3.2 or below does not return a result
259 # that tells whether it is timedout or not
260 return req['latch'].wait(WAIT_TIMEOUT)
261 else:
262 self.logger.error(
263 'cannot find a pending request (%s) from a queue' % req_id
264 )
265 raise ClientRPCError(
266 req_id,
267 'cannot find a pending request (%s) from a queue' % req_id
268 )
269
270 def __complete_request(self, req_id, result):
271 """
272 Compelete a pending request
273 """
274 if req_id in self.pending_requests:
275 req = self.pending_requests[req_id]
276 req['latch'].count_down()
277 req['result'] = result
278 return
279
280 self.logger.error(
281 'cannot find a pending request (%s) from a queue' % req_id
282 )
283 raise ClientRPCError(
284 req_id,
285 'cannot find a pending request (%s) from a queue' % req_id
286 )
287
288 def __pop_pending_request(self, req_name):
289 """
290 Pop a pending request from a queue
291 """
292 return self.pending_requests.pop(req_name, None)
293
294 def connect(self, url):
295 """
296 Connect to the given url
297 """
298 query_string = 'id=%s&type=workflow_manager&name=%s' % (self.name, self.name)
299 connect_url = '%s?%s' % (url, query_string)
300
301 if not (connect_url.startswith('http://') or connect_url.startswith('https://')):
302 connect_url = 'http://%s' % connect_url
303
304 self.logger.debug('Connecting to a Socket.IO server (%s)' % connect_url)
305 self.sio.connect(url=connect_url, transports=['websocket'])
306
307 def disconnect(self):
308 """
309 Disconnect from the server
310 """
311 self.sio.disconnect()
312
313 def wait(self):
314 self.sio.wait()
315
316 def sleep(self, sec):
317 self.sio.sleep(sec)
318
319 def get_handlers(self):
320 return self.handlers
321
322 def set_handlers(self, new_handlers):
323 for k in self.handlers:
324 if k in new_handlers:
325 self.handlers[k] = new_handlers[k]
326
327 def __request(self, api, params={}):
328 if api and params:
329 req_id = self.__put_pending_request(api, params)
330 self.logger.debug('waiting for a response for req_id (%s)' % req_id)
331 self.__wait_response(req_id) # wait for completion
332 req = self.__pop_pending_request(req_id)
333 if req:
334 if req['latch'].get_count() > 0:
335 # timed out
336 self.logger.error('request (%s) timed out' % req_id)
337 raise ClientRPCError(
338 req_id,
339 'request (%s) timed out' % req_id
340 )
341 else:
342 return req['result']
343 else:
344 self.logger.error('cannot find a pending request (%s) from a queue' % req_id)
345 raise ClientRPCError(
346 req_id,
347 'cannot find a pending request (%s) from a queue' % req_id
348 )
349 else:
350 self.logger.error(
351 'invalid arguments api (%s), params (%s)' %
352 (api, json.dumps(params))
353 )
354 raise ClientInputError(
355 'invalid arguments api (%s), params (%s)' %
356 (api, json.dumps(params))
357 )
358
359 def __on_response(self, api, result):
360 if result and 'req_id' in result:
361 self.logger.debug('completing a request (%s)' % result['req_id'])
362 self.__complete_request(result['req_id'], result)
363 else:
364 self.logger.error(
365 'invalid arguments api (%s), result (%s)' %
366 (api, json.dumps(result))
367 )
368 raise ClientInputError(
369 'invalid arguments api (%s), result (%s)' %
370 (api, json.dumps(result))
371 )
372
373 def register_workflow(self, workflow):
374 """
375 Register a workflow.
376 Workflow parameter is a workflow object
377 """
378 if workflow:
379 result = self.__request(WORKFLOW_REGISTER, {
380 'workflow': workflow
381 })
382 if result['error']:
383 self.logger.error(
384 'request (%s) failed with an error - %s' %
385 (result['req_id'], result['message'])
386 )
387 raise ClientResponseError(
388 'request (%s) failed with an error - %s' %
389 (result['req_id'], result['message'])
390 )
391 else:
392 return result['result']
393 else:
394 self.logger.error(
395 'invalid arguments workflow (%s)' %
396 json.dumps(workflow)
397 )
398 raise ClientInputError(
399 'invalid arguments workflow (%s)' %
400 json.dumps(workflow)
401 )
402
403 def register_workflow_essence(self, essence):
404 """
405 Register a workflow by essence.
406 """
407 if essence:
408 result = self.__request(WORKFLOW_REGISTER_ESSENCE, {
409 'essence': essence
410 })
411 if result['error']:
412 self.logger.error(
413 'request (%s) failed with an error - %s' %
414 (result['req_id'], result['message'])
415 )
416 raise ClientResponseError(
417 'request (%s) failed with an error - %s' %
418 (result['req_id'], result['message'])
419 )
420 else:
421 return result['result']
422 else:
423 self.logger.error(
424 'invalid arguments workflow essence (%s)' %
425 json.dumps(essence)
426 )
427 raise ClientInputError(
428 'invalid arguments workflow essence (%s)' %
429 json.dumps(essence)
430 )
431
432 def list_workflows(self):
433 """
434 List workflows.
435 """
436 result = self.__request(WORKFLOW_LIST, {})
437 if result['error']:
438 self.logger.error(
439 'request (%s) failed with an error - %s' %
440 (result['req_id'], result['message'])
441 )
442 raise ClientResponseError(
443 'request (%s) failed with an error - %s' %
444 (result['req_id'], result['message'])
445 )
446 else:
447 return result['result']
448
449 def list_workflow_runs(self):
450 """
451 List workflow runs.
452 """
453 result = self.__request(WORKFLOW_LIST_RUN, {})
454 if result['error']:
455 self.logger.error(
456 'request (%s) failed with an error - %s' %
457 (result['req_id'], result['message'])
458 )
459 raise ClientResponseError(
460 'request (%s) failed with an error - %s' %
461 (result['req_id'], result['message'])
462 )
463 else:
464 return result['result']
465
466 def check_workflow(self, workflow_id):
467 """
468 Check a workflow.
469 """
470 if workflow_id:
471 result = self.__request(WORKFLOW_CHECK, {
472 'workflow_id': workflow_id
473 })
474 if result['error']:
475 self.logger.error(
476 'request (%s) failed with an error - %s' %
477 (result['req_id'], result['message'])
478 )
479 raise ClientResponseError(
480 'request (%s) failed with an error - %s' %
481 (result['req_id'], result['message'])
482 )
483 else:
484 return result['result']
485 else:
486 self.logger.error(
487 'invalid arguments workflow_id (%s)' %
488 workflow_id
489 )
490 raise ClientInputError(
491 'invalid arguments workflow_id (%s)' %
492 workflow_id
493 )
494
495 def remove_workflow(self, workflow_id):
496 """
497 Remove a workflow.
498 """
499 if workflow_id:
500 result = self.__request(WORKFLOW_REMOVE, {
501 'workflow_id': workflow_id
502 })
503 if result['error']:
504 self.logger.error(
505 'request (%s) failed with an error - %s' %
506 (result['req_id'], result['message'])
507 )
508 raise ClientResponseError(
509 'request (%s) failed with an error - %s' %
510 (result['req_id'], result['message'])
511 )
512 else:
513 return result['result']
514 else:
515 self.logger.error(
516 'invalid arguments workflow_id (%s)' %
517 workflow_id
518 )
519 raise ClientInputError(
520 'invalid arguments workflow_id (%s)' %
521 workflow_id
522 )
523
524 def remove_workflow_run(self, workflow_id, workflow_run_id):
525 """
526 Remove a workflow run.
527 """
528 if workflow_id and workflow_run_id:
529 result = self.__request(WORKFLOW_REMOVE_RUN, {
530 'workflow_id': workflow_id,
531 'workflow_run_id': workflow_run_id
532 })
533 if result['error']:
534 self.logger.error(
535 'request (%s) failed with an error - %s' %
536 (result['req_id'], result['message'])
537 )
538 raise ClientResponseError(
539 'request (%s) failed with an error - %s' %
540 (result['req_id'], result['message'])
541 )
542 else:
543 return result['result']
544 else:
545 self.logger.error(
546 'invalid arguments workflow_id (%s) workflow_run_id (%s)' %
547 (workflow_id, workflow_run_id)
548 )
549 raise ClientInputError(
550 'invalid arguments workflow_id (%s) workflow_run_id (%s)' %
551 (workflow_id, workflow_run_id)
552 )
553
Illyoung Choi4df34b72019-07-18 13:55:18 -0700554 def report_new_workflow_run(self, workflow_id, workflow_run_id):
Illyoung Choia9d2c2c2019-07-12 13:29:42 -0700555 """
Illyoung Choi4df34b72019-07-18 13:55:18 -0700556 Report a new workflow run
Illyoung Choia9d2c2c2019-07-12 13:29:42 -0700557 """
558 if workflow_id and workflow_run_id:
Illyoung Choi4df34b72019-07-18 13:55:18 -0700559 result = self.__request(WORKFLOW_REPORT_NEW_RUN, {
Illyoung Choia9d2c2c2019-07-12 13:29:42 -0700560 'workflow_id': workflow_id,
561 'workflow_run_id': workflow_run_id
562 })
563 if result['error']:
564 self.logger.error(
565 'request (%s) failed with an error - %s' %
566 (result['req_id'], result['message'])
567 )
568 raise ClientResponseError(
569 'request (%s) failed with an error - %s' %
570 (result['req_id'], result['message'])
571 )
572 else:
573 return result['result']
574 else:
575 self.logger.error(
576 'invalid arguments workflow_id (%s), workflow_run_id (%s)' %
577 (workflow_id, workflow_run_id)
578 )
579 raise ClientInputError(
580 'invalid arguments workflow_id (%s), workflow_run_id (%s)' %
581 (workflow_id, workflow_run_id)
582 )
Illyoung Choi4df34b72019-07-18 13:55:18 -0700583
Illyoung Choib9ab43f2019-07-29 14:13:25 -0700584 def report_workflow_run_status(self, workflow_id, workflow_run_id, status):
Illyoung Choi4df34b72019-07-18 13:55:18 -0700585 """
Illyoung Choib9ab43f2019-07-29 14:13:25 -0700586 Report status of a workflow run
Illyoung Choi4df34b72019-07-18 13:55:18 -0700587 """
Illyoung Choib9ab43f2019-07-29 14:13:25 -0700588 if workflow_id and workflow_run_id and status:
589 result = self.__request(WORKFLOW_REPORT_RUN_STATUS, {
Illyoung Choi4df34b72019-07-18 13:55:18 -0700590 'workflow_id': workflow_id,
591 'workflow_run_id': workflow_run_id,
Illyoung Choib9ab43f2019-07-29 14:13:25 -0700592 'status': status
Illyoung Choi4df34b72019-07-18 13:55:18 -0700593 })
594 if result['error']:
595 self.logger.error(
596 'request (%s) failed with an error - %s' %
597 (result['req_id'], result['message'])
598 )
599 raise ClientResponseError(
600 'request (%s) failed with an error - %s' %
601 (result['req_id'], result['message'])
602 )
603 else:
604 return result['result']
605 else:
606 self.logger.error(
Illyoung Choib9ab43f2019-07-29 14:13:25 -0700607 'invalid arguments workflow_id (%s), workflow_run_id (%s), status (%s)' %
608 (workflow_id, workflow_run_id, status)
Illyoung Choi4df34b72019-07-18 13:55:18 -0700609 )
610 raise ClientInputError(
Illyoung Choib9ab43f2019-07-29 14:13:25 -0700611 'invalid arguments workflow_id (%s), workflow_run_id (%s), status (%s)' %
612 (workflow_id, workflow_run_id, status)
613 )
614
615 def report_workflow_run_status_bulk(self, data):
616 """
617 Report statuses of a workflow run
618 """
619
620 if data:
621 result = self.__request(WORKFLOW_REPORT_RUN_STATUS_BULK, {
622 'data': data
623 })
624 if result['error']:
625 self.logger.error(
626 'request (%s) failed with an error - %s' %
627 (result['req_id'], result['message'])
628 )
629 raise ClientResponseError(
630 'request (%s) failed with an error - %s' %
631 (result['req_id'], result['message'])
632 )
633 else:
634 return result['result']
635 else:
636 self.logger.error(
637 'invalid arguments data (%s)' %
638 json.dumps(data)
639 )
640 raise ClientInputError(
641 'invalid arguments data (%s)' %
642 json.dumps(data)
Illyoung Choi4df34b72019-07-18 13:55:18 -0700643 )