pgwidgets-python 0.1.3__py3-none-any.whl

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.
pgwidgets/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """
2
+ pgwidgets — Python bindings for the pgwidgets JavaScript widget library.
3
+
4
+ Usage (synchronous):
5
+ from pgwidgets.sync import Application
6
+ app = Application()
7
+ W = app.get_widgets()
8
+ top = W.TopLevel(title="Hello", resizable=True)
9
+ ...
10
+ app.run()
11
+
12
+ Usage (asynchronous):
13
+ from pgwidgets.async_ import Application
14
+ app = Application()
15
+ W = app.get_widgets()
16
+ top = await W.TopLevel(title="Hello", resizable=True)
17
+ ...
18
+ await app.run()
19
+ """
@@ -0,0 +1,24 @@
1
+ """
2
+ Asynchronous pgwidgets API.
3
+
4
+ Usage:
5
+ from pgwidgets.async_ import Application
6
+
7
+ app = Application()
8
+
9
+ @app.on_connect
10
+ async def setup(session):
11
+ W = session.get_widgets()
12
+ top = await W.TopLevel(title="Hello", resizable=True)
13
+ await top.resize(400, 300)
14
+ btn = await W.Button("Click me")
15
+ await btn.on("activated", my_handler)
16
+ await top.set_widget(btn)
17
+ await top.show()
18
+
19
+ await app.run()
20
+ """
21
+
22
+ from pgwidgets.async_.application import Application, Session
23
+
24
+ __all__ = ["Application", "Session"]
@@ -0,0 +1,660 @@
1
+ """
2
+ Asynchronous Application and Session classes.
3
+
4
+ Application — starts a WebSocket server and HTTP file server, manages
5
+ session lifecycle.
6
+
7
+ Session — one per browser connection, owns the widget tree and
8
+ callbacks for that connection.
9
+ """
10
+
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ import mimetypes
15
+ import traceback
16
+ from http.server import SimpleHTTPRequestHandler
17
+ from pathlib import Path
18
+
19
+ import websockets
20
+
21
+ from pgwidgets_js import get_static_path, get_remote_html
22
+ from pgwidgets.defs import WIDGETS
23
+ from pgwidgets.async_.widget import Widget, build_all_widget_classes
24
+
25
+ _CONCURRENCY_MODES = ("serialized", "per_session", "concurrent")
26
+
27
+
28
+ class _Namespace:
29
+ """Holds widget factory methods as attributes (W.Button, W.Label, etc.)."""
30
+ pass
31
+
32
+
33
+ class Session:
34
+ """
35
+ A single browser connection with its own widget tree (async).
36
+
37
+ Each browser tab that connects gets its own Session. The session
38
+ owns the widget map, callback registry, and message-ID counter for
39
+ that connection.
40
+
41
+ Parameters
42
+ ----------
43
+ app : Application
44
+ The owning Application.
45
+ ws : websockets.WebSocketServerProtocol
46
+ The WebSocket connection for this session.
47
+ session_id : int
48
+ Unique session identifier.
49
+ """
50
+
51
+ def __init__(self, app, ws, session_id):
52
+ self._app = app
53
+ self._ws = ws
54
+ self._id = session_id
55
+
56
+ self._next_id = 1
57
+ self._next_wid = 1
58
+ self._pending = {} # msg id -> Future
59
+ self._callbacks = {} # "wid:action" -> handler fn
60
+ self._widget_map = {} # wid -> Widget instance
61
+
62
+ self._widget_classes = app._widget_classes
63
+ self._transfers = {} # transfer_id -> transfer state dict
64
+
65
+ # Per-session lock (for "per_session" and "serialized" modes).
66
+ self._cb_lock = None
67
+
68
+ @property
69
+ def id(self):
70
+ """Unique session identifier."""
71
+ return self._id
72
+
73
+ @property
74
+ def app(self):
75
+ """The Application this session belongs to."""
76
+ return self._app
77
+
78
+ # -- Message handling --
79
+
80
+ def _handle_message(self, data):
81
+ msg = json.loads(data)
82
+ if isinstance(msg, list):
83
+ for m in msg:
84
+ self._handle_one(m)
85
+ else:
86
+ self._handle_one(msg)
87
+
88
+ def _handle_one(self, msg):
89
+ msg_type = msg.get("type")
90
+
91
+ if msg_type in ("result", "error"):
92
+ msg_id = msg.get("id")
93
+ future = self._pending.pop(msg_id, None)
94
+ if future and not future.done():
95
+ if msg_type == "error":
96
+ future.set_exception(RuntimeError(msg["error"]))
97
+ else:
98
+ future.set_result(msg)
99
+
100
+ elif msg_type == "file-chunk":
101
+ self._handle_file_chunk(msg)
102
+
103
+ elif msg_type == "callback":
104
+ # If the payload has a transfer_id, stash the metadata —
105
+ # the end callback fires after all chunks arrive.
106
+ if (msg.get("args")
107
+ and isinstance(msg["args"][0], dict)
108
+ and "transfer_id" in msg["args"][0]):
109
+ payload = msg["args"][0]
110
+ tid = payload["transfer_id"]
111
+ action = msg["action"]
112
+ self._transfers[tid] = {
113
+ "wid": msg["wid"],
114
+ "action": action,
115
+ "payload": payload,
116
+ "file_data": {}, # file_index -> [chunk, ...]
117
+ "num_chunks": {}, # file_index -> expected count
118
+ }
119
+ # Fire a start callback with metadata (no file data).
120
+ if action == "drop-end":
121
+ self._dispatch_callback(
122
+ msg["wid"], "drop-start", payload)
123
+ return
124
+
125
+ self._dispatch_callback(
126
+ msg["wid"], msg["action"], *msg.get("args", []))
127
+
128
+ def _handle_file_chunk(self, msg):
129
+ """Handle a file-chunk message: buffer data and fire callbacks."""
130
+ tid = msg["transfer_id"]
131
+ transfer = self._transfers.get(tid)
132
+ if transfer is None:
133
+ return
134
+
135
+ fi = msg["file_index"]
136
+ fc = msg["file_count"]
137
+ if fi not in transfer["file_data"]:
138
+ transfer["file_data"][fi] = []
139
+ transfer["num_chunks"][fi] = msg["num_chunks"]
140
+ transfer["file_data"][fi].append(msg["data"])
141
+
142
+ # Check if all files have received all their chunks.
143
+ all_complete = (
144
+ len(transfer["num_chunks"]) == fc
145
+ and all(
146
+ len(transfer["file_data"][i]) >= transfer["num_chunks"][i]
147
+ for i in range(fc)
148
+ )
149
+ )
150
+
151
+ # Compute byte-level progress from file sizes and chunk counts.
152
+ files_meta = transfer["payload"]["files"]
153
+ transferred_bytes = 0
154
+ total_bytes = 0
155
+ for i, fmeta in enumerate(files_meta):
156
+ fsize = fmeta.get("size", 0)
157
+ total_bytes += fsize
158
+ nc = transfer["num_chunks"].get(i)
159
+ if nc:
160
+ received = len(transfer["file_data"].get(i, []))
161
+ transferred_bytes += fsize * received // nc
162
+
163
+ progress_info = {
164
+ "transfer_id": tid,
165
+ "file_index": fi,
166
+ "chunk_index": msg["chunk_index"],
167
+ "num_chunks": msg["num_chunks"],
168
+ "transferred_bytes": transferred_bytes,
169
+ "total_bytes": total_bytes,
170
+ "complete": all_complete,
171
+ }
172
+ # Map original action to its progress callback name.
173
+ action = transfer["action"]
174
+ progress_action = ("drop-progress" if action == "drop-end"
175
+ else "progress")
176
+ self._dispatch_callback(
177
+ transfer["wid"], progress_action, progress_info)
178
+
179
+ if all_complete:
180
+ # Reassemble file data and fire the original callback.
181
+ payload = transfer["payload"]
182
+ for i, file_meta in enumerate(payload["files"]):
183
+ file_meta["data"] = "".join(
184
+ transfer["file_data"].get(i, []))
185
+ del self._transfers[tid]
186
+ self._dispatch_callback(
187
+ transfer["wid"], action, payload)
188
+
189
+ def _dispatch_callback(self, wid, action, *args):
190
+ """Dispatch a callback through the configured concurrency mode."""
191
+ key = f"{wid}:{action}"
192
+ handler = self._callbacks.get(key)
193
+ if not handler:
194
+ return
195
+ cb_args = (wid, *args)
196
+ mode = self._app._concurrency
197
+ if mode == "concurrent":
198
+ asyncio.ensure_future(handler(*cb_args))
199
+ elif mode == "per_session":
200
+ asyncio.ensure_future(
201
+ self._serialized_dispatch(
202
+ handler, cb_args, self._cb_lock))
203
+ else: # serialized
204
+ asyncio.ensure_future(
205
+ self._serialized_dispatch(
206
+ handler, cb_args, self._app._cb_lock))
207
+
208
+ @staticmethod
209
+ async def _serialized_dispatch(handler, args, lock):
210
+ """Run handler under a lock for serialized execution."""
211
+ async with lock:
212
+ try:
213
+ result = handler(*args)
214
+ if hasattr(result, "__await__"):
215
+ await result
216
+ except Exception:
217
+ traceback.print_exc()
218
+
219
+ async def _send(self, msg):
220
+ """Send a message and wait for the result."""
221
+ msg_id = self._next_id
222
+ self._next_id += 1
223
+ msg["id"] = msg_id
224
+ loop = asyncio.get_event_loop()
225
+ future = loop.create_future()
226
+ self._pending[msg_id] = future
227
+ await self._ws.send(json.dumps(msg))
228
+ return await future
229
+
230
+ def _alloc_wid(self):
231
+ wid = self._next_wid
232
+ self._next_wid += 1
233
+ return wid
234
+
235
+ # -- Low-level widget API --
236
+
237
+ async def _create(self, js_class, *args):
238
+ """Create a JS widget and return its wid."""
239
+ wid = self._alloc_wid()
240
+ resolved = [self._resolve_arg(a) for a in args]
241
+ await self._send({
242
+ "type": "create",
243
+ "wid": wid,
244
+ "class": js_class,
245
+ "args": resolved,
246
+ })
247
+ return wid
248
+
249
+ async def _call(self, wid, method, *args):
250
+ """Call a method on a JS widget."""
251
+ result = await self._send({
252
+ "type": "call",
253
+ "wid": wid,
254
+ "method": method,
255
+ "args": list(args),
256
+ })
257
+ return result.get("value")
258
+
259
+ async def _listen(self, wid, action, handler):
260
+ """Register a callback listener."""
261
+ key = f"{wid}:{action}"
262
+ self._callbacks[key] = handler
263
+ await self._send({
264
+ "type": "listen",
265
+ "wid": wid,
266
+ "action": action,
267
+ })
268
+
269
+ async def _unlisten(self, wid, action):
270
+ """Remove a callback listener."""
271
+ key = f"{wid}:{action}"
272
+ self._callbacks.pop(key, None)
273
+ await self._send({
274
+ "type": "unlisten",
275
+ "wid": wid,
276
+ "action": action,
277
+ })
278
+
279
+ def _resolve_arg(self, arg):
280
+ """Convert Widget instances to wire refs in outgoing args."""
281
+ if isinstance(arg, Widget):
282
+ return {"__wid__": arg.wid}
283
+ if isinstance(arg, list):
284
+ return [self._resolve_arg(a) for a in arg]
285
+ if isinstance(arg, dict):
286
+ return {k: self._resolve_arg(v) for k, v in arg.items()}
287
+ return arg
288
+
289
+ def _resolve_return(self, val):
290
+ """Convert wire refs back to Widget instances in return values."""
291
+ if isinstance(val, dict) and "__wid__" in val:
292
+ wid = val["__wid__"]
293
+ return self._widget_map.get(wid, val)
294
+ if isinstance(val, list):
295
+ return [self._resolve_return(v) for v in val]
296
+ return val
297
+
298
+ # -- Widget factory --
299
+
300
+ def get_widgets(self):
301
+ """Return a namespace with async factory methods for all widget types.
302
+
303
+ Usage:
304
+ Widgets = session.get_widgets()
305
+ btn = await Widgets.Button("Click me")
306
+ """
307
+ ns = _Namespace()
308
+ session = self
309
+
310
+ for js_class, cls in self._widget_classes.items():
311
+ defn = WIDGETS[js_class]
312
+
313
+ def make_factory(js_cls, widget_cls, widget_defn):
314
+ async def factory(*args, **kwargs):
315
+ pos_names = widget_defn.get("args", [])
316
+ opt_names = widget_defn.get("options", [])
317
+
318
+ js_args = list(args[:len(pos_names)])
319
+
320
+ for i, val in enumerate(args[len(pos_names):]):
321
+ if i < len(opt_names):
322
+ kwargs[opt_names[i]] = val
323
+
324
+ options = {}
325
+ for k in list(kwargs.keys()):
326
+ if k in opt_names:
327
+ options[k] = kwargs.pop(k)
328
+
329
+ if options:
330
+ js_args.append(options)
331
+
332
+ wid = await session._create(js_cls, *js_args)
333
+ widget = widget_cls(session, wid, js_cls)
334
+ session._widget_map[wid] = widget
335
+
336
+ for k, v in kwargs.items():
337
+ setter = f"set_{k}"
338
+ if hasattr(widget, setter):
339
+ await getattr(widget, setter)(v)
340
+ else:
341
+ raise TypeError(
342
+ f"{js_cls}() got unexpected keyword "
343
+ f"argument '{k}'")
344
+
345
+ return widget
346
+
347
+ factory.__name__ = js_cls
348
+ factory.__qualname__ = js_cls
349
+ return factory
350
+
351
+ setattr(ns, js_class, make_factory(js_class, cls, defn))
352
+
353
+ return ns
354
+
355
+ async def make_timer(self, duration=0):
356
+ """Create a Timer (non-visual) and return its widget wrapper."""
357
+ ns = self.get_widgets()
358
+ return await ns.Timer(duration=duration)
359
+
360
+ async def close(self):
361
+ """Close this session's WebSocket connection."""
362
+ if self._ws is not None:
363
+ await self._ws.close()
364
+
365
+ def __repr__(self):
366
+ return f"<Session id={self._id}>"
367
+
368
+
369
+ class Application:
370
+ """
371
+ Main entry point for an async pgwidgets application.
372
+
373
+ Creates a WebSocket server for widget commands and optionally an HTTP
374
+ server to serve the JS/CSS assets. Each browser connection gets its
375
+ own Session.
376
+
377
+ Parameters
378
+ ----------
379
+ ws_port : int
380
+ WebSocket server port (default 9500).
381
+ http_port : int
382
+ HTTP file server port (default 9501). Ignored if http_server=False.
383
+ host : str
384
+ Bind address (default '127.0.0.1').
385
+ http_server : bool
386
+ Whether to start the built-in HTTP server (default True).
387
+ Set to False if you are serving the pgwidgets static files
388
+ from your own HTTP/HTTPS server (e.g. FastAPI, aiohttp, nginx).
389
+ concurrency_handling : str
390
+ How widget callbacks are dispatched. One of:
391
+
392
+ ``"per_session"`` (default)
393
+ Each session gets its own asyncio.Lock. Callbacks within
394
+ a session are serialized, but different sessions' callbacks
395
+ can interleave at await points.
396
+ ``"serialized"``
397
+ All callbacks from all sessions are serialized under a
398
+ single global asyncio.Lock.
399
+ ``"concurrent"``
400
+ Callbacks are dispatched freely via ensure_future with no
401
+ serialization.
402
+ max_sessions : int or None
403
+ Maximum number of concurrent sessions (default 1).
404
+ Set to None for unlimited. When the limit is reached, new
405
+ connections are held until an existing session disconnects.
406
+ logger : logging.Logger or None
407
+ Logger for status messages. If None (default), a null logger
408
+ is used and no output is produced.
409
+ """
410
+
411
+ def __init__(self, ws_port=9500, http_port=9501, host="127.0.0.1",
412
+ http_server=True, concurrency_handling="per_session",
413
+ max_sessions=1, logger=None):
414
+ if concurrency_handling not in _CONCURRENCY_MODES:
415
+ raise ValueError(
416
+ f"concurrency_handling must be one of "
417
+ f"{_CONCURRENCY_MODES!r}, got {concurrency_handling!r}")
418
+ self._host = host
419
+ self._ws_port = ws_port
420
+ self._http_port = http_port
421
+ self._use_http_server = http_server
422
+ self._concurrency = concurrency_handling
423
+ self._max_sessions = max_sessions
424
+
425
+ if logger is None:
426
+ logger = logging.getLogger("pgwidgets")
427
+ logger.addHandler(logging.NullHandler())
428
+ self._logger = logger
429
+
430
+ self._favicon_path = Path(get_static_path()) / "icons" / "pgicon.svg"
431
+
432
+ self._sessions = {} # session_id -> Session
433
+ self._next_session_id = 1
434
+ self._on_connect = None # user callback: fn(session)
435
+ self._on_disconnect = None # user callback: fn(session)
436
+ self._session_semaphore = None # initialized in start()
437
+ self._cb_lock = None # for "serialized" mode
438
+
439
+ self._run_future = None # set in run(), cancelled by close()
440
+ self._httpd = None # HTTP server instance
441
+
442
+ # build widget classes once, shared by all sessions
443
+ self._widget_classes = build_all_widget_classes()
444
+
445
+ def on_connect(self, handler):
446
+ """Register a callback invoked when a new session is created.
447
+
448
+ The handler receives one argument: the Session object.
449
+ Handler can be sync or async. Use it to build the UI::
450
+
451
+ @app.on_connect
452
+ async def setup(session):
453
+ Widgets = session.get_widgets()
454
+ top = await Widgets.TopLevel(title="Hello")
455
+ await top.show()
456
+
457
+ Can also be used as a decorator.
458
+ """
459
+ self._on_connect = handler
460
+ return handler
461
+
462
+ def on_disconnect(self, handler):
463
+ """Register a callback invoked when a session disconnects.
464
+
465
+ The handler receives one argument: the Session object.
466
+ Handler can be sync or async. Can also be used as a decorator.
467
+ """
468
+ self._on_disconnect = handler
469
+ return handler
470
+
471
+ @property
472
+ def sessions(self):
473
+ """Dict of active sessions (session_id -> Session)."""
474
+ return dict(self._sessions)
475
+
476
+ @property
477
+ def url(self):
478
+ if self._use_http_server:
479
+ return f"http://{self._host}:{self._http_port}/"
480
+ return None
481
+
482
+ @property
483
+ def static_path(self):
484
+ """Path to the pgwidgets static files directory."""
485
+ return get_static_path()
486
+
487
+ @property
488
+ def remote_html(self):
489
+ """Path to the remote.html connector page."""
490
+ return get_remote_html()
491
+
492
+ def set_favicon(self, path):
493
+ """Set a custom favicon for the built-in HTTP server.
494
+
495
+ Call this before start() to override the default pgwidgets icon.
496
+
497
+ Parameters
498
+ ----------
499
+ path : str or Path
500
+ Path to an image file (SVG, PNG, ICO, etc.).
501
+ """
502
+ self._favicon_path = Path(path)
503
+
504
+ # -- WebSocket handling --
505
+
506
+ async def _ws_handler(self, ws):
507
+ # If max_sessions is set, wait for a slot.
508
+ if self._session_semaphore is not None:
509
+ await self._session_semaphore.acquire()
510
+
511
+ # Allocate session.
512
+ session_id = self._next_session_id
513
+ self._next_session_id += 1
514
+
515
+ session = Session(self, ws, session_id)
516
+
517
+ # Set up per-session lock if needed.
518
+ if self._concurrency == "per_session":
519
+ session._cb_lock = asyncio.Lock()
520
+
521
+ # Init handshake: reset the browser to a clean slate.
522
+ await ws.send(json.dumps({"type": "init", "id": 0}))
523
+ await ws.recv() # wait for ack
524
+
525
+ self._sessions[session_id] = session
526
+ self._logger.info(f"Session {session_id} connected.")
527
+
528
+ # Launch on_connect as a concurrent task so it runs alongside
529
+ # the message loop below — on_connect sends widget commands
530
+ # whose responses must be read by the message loop.
531
+ if self._on_connect:
532
+ result = self._on_connect(session)
533
+ if hasattr(result, "__await__"):
534
+ asyncio.ensure_future(result)
535
+
536
+ try:
537
+ async for message in ws:
538
+ session._handle_message(message)
539
+ finally:
540
+ self._sessions.pop(session_id, None)
541
+
542
+ self._logger.info(f"Session {session_id} disconnected.")
543
+
544
+ if self._on_disconnect:
545
+ result = self._on_disconnect(session)
546
+ if hasattr(result, "__await__"):
547
+ await result
548
+
549
+ if self._session_semaphore is not None:
550
+ self._session_semaphore.release()
551
+
552
+ # -- HTTP server --
553
+
554
+ async def _start_http_server(self):
555
+ """Start a simple HTTP server to serve the JS/CSS assets."""
556
+ static_path = str(get_static_path())
557
+ remote_html = get_remote_html()
558
+ favicon_path = self._favicon_path
559
+ ws_host = self._host
560
+ ws_port = self._ws_port
561
+
562
+ class Handler(SimpleHTTPRequestHandler):
563
+ def __init__(self, *a, **kw):
564
+ super().__init__(*a, directory=static_path, **kw)
565
+
566
+ def do_GET(self):
567
+ if self.path == "/" or self.path == "/index.html":
568
+ html = remote_html.read_text(encoding="utf-8")
569
+ inject = (
570
+ f'<script>window.PGWIDGETS_WS_URL'
571
+ f' = "ws://{ws_host}:{ws_port}";</script>\n')
572
+ html = html.replace("<head>",
573
+ "<head>\n" + inject, 1)
574
+ body = html.encode("utf-8")
575
+ self.send_response(200)
576
+ self.send_header("Content-Type",
577
+ "text/html; charset=utf-8")
578
+ self.send_header("Content-Length", str(len(body)))
579
+ self.end_headers()
580
+ self.wfile.write(body)
581
+ return
582
+ if self.path == "/favicon.svg" or self.path == "/favicon.ico":
583
+ if favicon_path and favicon_path.is_file():
584
+ mime, _ = mimetypes.guess_type(str(favicon_path))
585
+ self.send_response(200)
586
+ self.send_header("Content-Type",
587
+ mime or "image/svg+xml")
588
+ self.end_headers()
589
+ self.wfile.write(favicon_path.read_bytes())
590
+ else:
591
+ self.send_error(404)
592
+ return
593
+ super().do_GET()
594
+
595
+ def log_message(self, format, *args):
596
+ pass
597
+
598
+ loop = asyncio.get_event_loop()
599
+ import http.server
600
+ self._httpd = http.server.HTTPServer(
601
+ (self._host, self._http_port), Handler)
602
+ await loop.run_in_executor(None, self._httpd.serve_forever)
603
+
604
+ # -- Main loop --
605
+
606
+ async def start(self):
607
+ """Start the WebSocket server (and HTTP server if enabled).
608
+
609
+ Call this after construction and any customisation. Subclasses
610
+ can override to add extra setup before or after the servers start.
611
+ """
612
+ if self._max_sessions is not None:
613
+ self._session_semaphore = asyncio.Semaphore(
614
+ self._max_sessions)
615
+
616
+ if self._concurrency == "serialized":
617
+ self._cb_lock = asyncio.Lock()
618
+
619
+ if self._use_http_server:
620
+ self._logger.info(f"Open {self.url} in a browser to connect.")
621
+ asyncio.ensure_future(self._start_http_server())
622
+ self._logger.info(
623
+ f"WebSocket on ws://{self._host}:{self._ws_port}")
624
+
625
+ self._ws_server = await websockets.serve(
626
+ self._ws_handler, self._host, self._ws_port)
627
+
628
+ async def close(self):
629
+ """Close all sessions and shut down the application.
630
+
631
+ Causes run() to return so the program can exit cleanly.
632
+ """
633
+ # Close all active sessions.
634
+ for session in list(self._sessions.values()):
635
+ await session.close()
636
+
637
+ # Stop the WebSocket server.
638
+ if hasattr(self, '_ws_server'):
639
+ self._ws_server.close()
640
+ await self._ws_server.wait_closed()
641
+
642
+ # Stop the HTTP server (runs in a thread).
643
+ if self._httpd is not None:
644
+ self._httpd.shutdown()
645
+
646
+ # Cancel the run() future so run() returns.
647
+ if self._run_future is not None and not self._run_future.done():
648
+ self._run_future.cancel()
649
+
650
+ async def run(self):
651
+ """Start servers and run forever. Ctrl-C to exit."""
652
+ await self.start()
653
+ self._run_future = asyncio.get_event_loop().create_future()
654
+ try:
655
+ await self._run_future
656
+ except asyncio.CancelledError:
657
+ pass
658
+ finally:
659
+ await self.close()
660
+ self._logger.info("Shutting down.")