listenflow 0.17.6__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.
- listenflow/__init__.py +101 -0
- listenflow/_async.py +23 -0
- listenflow/_version.py +24 -0
- listenflow/base.py +317 -0
- listenflow/engine.py +926 -0
- listenflow/errors.py +29 -0
- listenflow/events.py +37 -0
- listenflow/health.py +36 -0
- listenflow/sync.py +246 -0
- listenflow-0.17.6.dist-info/METADATA +275 -0
- listenflow-0.17.6.dist-info/RECORD +13 -0
- listenflow-0.17.6.dist-info/WHEEL +4 -0
- listenflow-0.17.6.dist-info/licenses/LICENSE +21 -0
listenflow/__init__.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""listenflow core library: PCM stream input, transcript events output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
|
|
7
|
+
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
|
|
8
|
+
|
|
9
|
+
from listenflow._version import __version__
|
|
10
|
+
from listenflow.engine import (
|
|
11
|
+
ClosedUtterance,
|
|
12
|
+
DiarizationMode,
|
|
13
|
+
EngineCapabilities,
|
|
14
|
+
EngineInputFormat,
|
|
15
|
+
EngineTransport,
|
|
16
|
+
FinalizationMode,
|
|
17
|
+
FinalObservation,
|
|
18
|
+
OfflineModelDirectorySource,
|
|
19
|
+
PartialMode,
|
|
20
|
+
ProvisionalObservation,
|
|
21
|
+
TimestampDetail,
|
|
22
|
+
UtteranceBoundarySettings,
|
|
23
|
+
UtteranceBoundarySession,
|
|
24
|
+
UtteranceDecodePipeline,
|
|
25
|
+
has_voice,
|
|
26
|
+
)
|
|
27
|
+
from listenflow.base import STTEngineBase
|
|
28
|
+
from listenflow.events import Final, Partial, TranscriptEvent, UtteranceEnd, UtteranceStart
|
|
29
|
+
from listenflow.health import BiasApplicationSnapshot, EngineState, HealthSnapshot
|
|
30
|
+
from listenflow.sync import iterate_async, iterate_sync, run_sync
|
|
31
|
+
from listenflow.errors import (
|
|
32
|
+
ConfigurationError,
|
|
33
|
+
EngineClosedError,
|
|
34
|
+
EngineStateError,
|
|
35
|
+
EnvironmentUnavailableError,
|
|
36
|
+
InferenceError,
|
|
37
|
+
ModuleNotInstalledError,
|
|
38
|
+
STTError,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"BiasApplicationSnapshot",
|
|
43
|
+
"ClosedUtterance",
|
|
44
|
+
"ConfigurationError",
|
|
45
|
+
"DiarizationMode",
|
|
46
|
+
"EngineCapabilities",
|
|
47
|
+
"EngineClosedError",
|
|
48
|
+
"EngineInputFormat",
|
|
49
|
+
"EngineState",
|
|
50
|
+
"EngineStateError",
|
|
51
|
+
"EngineTransport",
|
|
52
|
+
"EnvironmentUnavailableError",
|
|
53
|
+
"Final",
|
|
54
|
+
"FinalObservation",
|
|
55
|
+
"FinalizationMode",
|
|
56
|
+
"HealthSnapshot",
|
|
57
|
+
"InferenceError",
|
|
58
|
+
"ModuleNotInstalledError",
|
|
59
|
+
"OfflineModelDirectorySource",
|
|
60
|
+
"Partial",
|
|
61
|
+
"PartialMode",
|
|
62
|
+
"ProvisionalObservation",
|
|
63
|
+
"STTEngineBase",
|
|
64
|
+
"STTError",
|
|
65
|
+
"TimestampDetail",
|
|
66
|
+
"TranscriptEvent",
|
|
67
|
+
"UtteranceEnd",
|
|
68
|
+
"UtteranceStart",
|
|
69
|
+
"UtteranceBoundarySettings",
|
|
70
|
+
"UtteranceBoundarySession",
|
|
71
|
+
"UtteranceDecodePipeline",
|
|
72
|
+
"__version__",
|
|
73
|
+
"has_voice",
|
|
74
|
+
"iterate_async",
|
|
75
|
+
"iterate_sync",
|
|
76
|
+
"run_sync",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
# Add engine modules here when they are published on PyPI.
|
|
80
|
+
_MODULE_DISTRIBUTIONS: dict[str, str] = {}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def __getattr__(name: str) -> object:
|
|
84
|
+
try:
|
|
85
|
+
distribution = _MODULE_DISTRIBUTIONS[name]
|
|
86
|
+
except KeyError:
|
|
87
|
+
raise AttributeError(name) from None
|
|
88
|
+
|
|
89
|
+
module_name = f"{__name__}.{name}"
|
|
90
|
+
try:
|
|
91
|
+
module = importlib.import_module(module_name)
|
|
92
|
+
except ModuleNotFoundError as exc:
|
|
93
|
+
if exc.name != module_name:
|
|
94
|
+
raise
|
|
95
|
+
raise ModuleNotInstalledError(
|
|
96
|
+
f"why={module_name} is unavailable because {distribution} is not installed; "
|
|
97
|
+
f"what={distribution} provides {module_name}; "
|
|
98
|
+
f"how=pip install {distribution}"
|
|
99
|
+
) from exc
|
|
100
|
+
globals()[name] = module
|
|
101
|
+
return module
|
listenflow/_async.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Wait for owned asynchronous work without interrupting its cleanup twice."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def cancel_once(task: asyncio.Task[object]) -> None:
|
|
9
|
+
if not task.done() and not task.cancelling():
|
|
10
|
+
task.cancel()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def finish(task: asyncio.Future[object]) -> None:
|
|
14
|
+
while not task.done():
|
|
15
|
+
try:
|
|
16
|
+
# Wait without adding this frame and its callers to a failed task's traceback.
|
|
17
|
+
await asyncio.wait((task,))
|
|
18
|
+
except asyncio.CancelledError:
|
|
19
|
+
continue
|
|
20
|
+
except BaseException:
|
|
21
|
+
break
|
|
22
|
+
if not task.cancelled():
|
|
23
|
+
task.exception()
|
listenflow/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.17.6'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 17, 6)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
listenflow/base.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import AsyncIterator, Coroutine, Mapping
|
|
7
|
+
from typing import Self
|
|
8
|
+
|
|
9
|
+
from listenflow._async import cancel_once as _cancel, finish as _finish
|
|
10
|
+
from listenflow.engine import EngineCapabilities, EngineInputFormat, validate_pcm_chunk
|
|
11
|
+
from listenflow.errors import (
|
|
12
|
+
ConfigurationError,
|
|
13
|
+
EngineClosedError,
|
|
14
|
+
EngineStateError,
|
|
15
|
+
InferenceError,
|
|
16
|
+
)
|
|
17
|
+
from listenflow.events import TranscriptEvent
|
|
18
|
+
from listenflow.health import EngineState, HealthSnapshot
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class STTEngineBase(ABC):
|
|
22
|
+
"""Shared preparation, recognition, closure, and health behavior.
|
|
23
|
+
|
|
24
|
+
Use one engine instance on one event loop. Modules implement the hooks,
|
|
25
|
+
map native errors into STTError subclasses, and finish native work before
|
|
26
|
+
returning from a cancelled hook. Failed loads clean up their own resources.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._model_lock = asyncio.Lock()
|
|
31
|
+
self._operations: set[asyncio.Task[None]] = set()
|
|
32
|
+
self._stream_started = asyncio.Event()
|
|
33
|
+
self._loaded = False
|
|
34
|
+
self._close_task: asyncio.Task[None] | None = None
|
|
35
|
+
self._last_progress_monotonic_s: float | None = None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def capabilities(self) -> EngineCapabilities:
|
|
40
|
+
"""Return the module's immutable, static capability declaration."""
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
async def _load(self) -> None:
|
|
45
|
+
"""Load resources and run a trial inference; clean up a failed load."""
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
async def _release(self) -> None:
|
|
50
|
+
"""Return resources created by a successful load."""
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def _recognize(self, pcm_chunks: AsyncIterator[bytes]) -> AsyncIterator[TranscriptEvent]:
|
|
55
|
+
"""Recognize PCM; cancellation and iterator closure must finish native work."""
|
|
56
|
+
raise NotImplementedError
|
|
57
|
+
|
|
58
|
+
def _health_details(self) -> Mapping[str, object]:
|
|
59
|
+
"""Return recognition fields only, without the three shared health fields."""
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
def _mark_progress(self) -> None:
|
|
63
|
+
"""Record a processed PCM chunk or completed utterance decode."""
|
|
64
|
+
self._last_progress_monotonic_s = time.monotonic()
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def _closing(self) -> bool:
|
|
68
|
+
return self._close_task is not None
|
|
69
|
+
|
|
70
|
+
def _check_open(self) -> None:
|
|
71
|
+
if self._closing:
|
|
72
|
+
raise EngineClosedError("engine is closed")
|
|
73
|
+
|
|
74
|
+
def _start(self, coroutine: Coroutine[object, object, None]) -> asyncio.Task[None]:
|
|
75
|
+
task = asyncio.create_task(coroutine)
|
|
76
|
+
self._operations.add(task)
|
|
77
|
+
task.add_done_callback(self._operations.discard)
|
|
78
|
+
return task
|
|
79
|
+
|
|
80
|
+
async def prepare(self) -> None:
|
|
81
|
+
"""Load and trial the model once; retry after an unsuccessful load."""
|
|
82
|
+
self._check_open()
|
|
83
|
+
if self._loaded:
|
|
84
|
+
return
|
|
85
|
+
task = self._start(self._prepare())
|
|
86
|
+
try:
|
|
87
|
+
await asyncio.shield(task)
|
|
88
|
+
except asyncio.CancelledError:
|
|
89
|
+
caller = asyncio.current_task()
|
|
90
|
+
if self._closing and caller is not None and not caller.cancelling():
|
|
91
|
+
raise EngineClosedError("engine closed while preparing") from None
|
|
92
|
+
_cancel(task)
|
|
93
|
+
await _finish(task)
|
|
94
|
+
raise
|
|
95
|
+
except Exception as error:
|
|
96
|
+
if self._closing:
|
|
97
|
+
raise EngineClosedError("engine closed while preparing") from error
|
|
98
|
+
raise
|
|
99
|
+
finally:
|
|
100
|
+
# A failed task retains this frame through the exception traceback.
|
|
101
|
+
del task
|
|
102
|
+
|
|
103
|
+
async def _prepare(self) -> None:
|
|
104
|
+
acquired = False
|
|
105
|
+
try:
|
|
106
|
+
await self._acquire_model_slot()
|
|
107
|
+
acquired = True
|
|
108
|
+
self._check_open()
|
|
109
|
+
if not self._loaded:
|
|
110
|
+
await self._load()
|
|
111
|
+
self._loaded = True
|
|
112
|
+
self._check_open()
|
|
113
|
+
except asyncio.CancelledError:
|
|
114
|
+
if self._closing:
|
|
115
|
+
raise EngineClosedError("engine closed while preparing") from None
|
|
116
|
+
raise
|
|
117
|
+
finally:
|
|
118
|
+
if acquired:
|
|
119
|
+
self._model_lock.release()
|
|
120
|
+
|
|
121
|
+
def stream(self, pcm_chunks: AsyncIterator[bytes]) -> AsyncIterator[TranscriptEvent]:
|
|
122
|
+
"""Start recognition on the first read and keep its slot through cleanup.
|
|
123
|
+
|
|
124
|
+
The returned iterator supports aclose(). Close it explicitly when stopping
|
|
125
|
+
before the end, or use contextlib.aclosing around the iterator.
|
|
126
|
+
"""
|
|
127
|
+
return _EngineStream(self, pcm_chunks)
|
|
128
|
+
|
|
129
|
+
async def _acquire_model_slot(self, *, recognition: bool = False) -> None:
|
|
130
|
+
self._check_open()
|
|
131
|
+
if recognition and self._stream_started.is_set():
|
|
132
|
+
raise EngineStateError("engine already has an active stream")
|
|
133
|
+
acquire = asyncio.create_task(self._model_lock.acquire())
|
|
134
|
+
overlap = asyncio.create_task(self._stream_started.wait()) if recognition else None
|
|
135
|
+
try:
|
|
136
|
+
if overlap is not None:
|
|
137
|
+
await asyncio.wait((acquire, overlap), return_when=asyncio.FIRST_COMPLETED)
|
|
138
|
+
self._check_open()
|
|
139
|
+
if overlap is not None and overlap.done():
|
|
140
|
+
raise EngineStateError("engine already has an active stream")
|
|
141
|
+
await acquire
|
|
142
|
+
except BaseException:
|
|
143
|
+
_cancel(acquire)
|
|
144
|
+
if overlap is not None:
|
|
145
|
+
_cancel(overlap)
|
|
146
|
+
await _finish(overlap)
|
|
147
|
+
await _finish(acquire)
|
|
148
|
+
if not acquire.cancelled() and acquire.exception() is None:
|
|
149
|
+
self._model_lock.release()
|
|
150
|
+
raise
|
|
151
|
+
finally:
|
|
152
|
+
if overlap is not None:
|
|
153
|
+
_cancel(overlap)
|
|
154
|
+
|
|
155
|
+
async def _validated_pcm(self, pcm_chunks: AsyncIterator[bytes]) -> AsyncIterator[bytes]:
|
|
156
|
+
input_format = self.capabilities.input_format
|
|
157
|
+
async for chunk in pcm_chunks:
|
|
158
|
+
if input_format in (EngineInputFormat.PCM_S16LE, EngineInputFormat.PCM_F32):
|
|
159
|
+
try:
|
|
160
|
+
validate_pcm_chunk(chunk, input_format=input_format)
|
|
161
|
+
except ValueError as error:
|
|
162
|
+
raise ConfigurationError(str(error)) from error
|
|
163
|
+
yield chunk
|
|
164
|
+
|
|
165
|
+
async def _run_stream(self, stream: _EngineStream) -> None:
|
|
166
|
+
iterator: AsyncIterator[TranscriptEvent] | None = None
|
|
167
|
+
acquired = False
|
|
168
|
+
recognizing = False
|
|
169
|
+
try:
|
|
170
|
+
await self._acquire_model_slot(recognition=True)
|
|
171
|
+
acquired = True
|
|
172
|
+
if not self._loaded:
|
|
173
|
+
raise EngineStateError("prepare the engine before recognition")
|
|
174
|
+
self._stream_started.set()
|
|
175
|
+
recognizing = True
|
|
176
|
+
iterator = aiter(self._recognize(self._validated_pcm(stream._pcm_chunks)))
|
|
177
|
+
while True:
|
|
178
|
+
request = await stream._requests.get()
|
|
179
|
+
try:
|
|
180
|
+
event = await anext(iterator)
|
|
181
|
+
except StopAsyncIteration:
|
|
182
|
+
self._check_open()
|
|
183
|
+
break
|
|
184
|
+
self._check_open()
|
|
185
|
+
if stream._closed:
|
|
186
|
+
break
|
|
187
|
+
if not request.done():
|
|
188
|
+
request.set_result(event)
|
|
189
|
+
except asyncio.CancelledError:
|
|
190
|
+
if self._closing:
|
|
191
|
+
raise EngineClosedError("engine closed during recognition") from None
|
|
192
|
+
raise
|
|
193
|
+
finally:
|
|
194
|
+
try:
|
|
195
|
+
if iterator is not None and hasattr(iterator, "aclose"):
|
|
196
|
+
await iterator.aclose()
|
|
197
|
+
finally:
|
|
198
|
+
if recognizing:
|
|
199
|
+
self._stream_started.clear()
|
|
200
|
+
if acquired:
|
|
201
|
+
self._model_lock.release()
|
|
202
|
+
# Do not let an exception traceback lead back to its task or Future.
|
|
203
|
+
request = None
|
|
204
|
+
del stream
|
|
205
|
+
|
|
206
|
+
async def aclose(self) -> None:
|
|
207
|
+
"""Cancel operations, wait for their cleanup, and release resources once."""
|
|
208
|
+
first = self._close_task is None
|
|
209
|
+
if first:
|
|
210
|
+
self._close_task = asyncio.create_task(self._close())
|
|
211
|
+
task = self._close_task
|
|
212
|
+
assert task is not None
|
|
213
|
+
try:
|
|
214
|
+
await asyncio.shield(task)
|
|
215
|
+
except asyncio.CancelledError:
|
|
216
|
+
await _finish(task)
|
|
217
|
+
raise
|
|
218
|
+
except BaseException:
|
|
219
|
+
if first:
|
|
220
|
+
raise
|
|
221
|
+
|
|
222
|
+
async def _close(self) -> None:
|
|
223
|
+
operations = tuple(self._operations)
|
|
224
|
+
for task in operations:
|
|
225
|
+
_cancel(task)
|
|
226
|
+
await asyncio.gather(*operations, return_exceptions=True)
|
|
227
|
+
if self._loaded:
|
|
228
|
+
self._loaded = False
|
|
229
|
+
try:
|
|
230
|
+
await self._release()
|
|
231
|
+
except Exception as error:
|
|
232
|
+
raise InferenceError("engine resource release failed") from error
|
|
233
|
+
|
|
234
|
+
async def __aenter__(self) -> Self:
|
|
235
|
+
await self.prepare()
|
|
236
|
+
return self
|
|
237
|
+
|
|
238
|
+
async def __aexit__(self, exc_type, exc_value, traceback) -> None:
|
|
239
|
+
await self.aclose()
|
|
240
|
+
|
|
241
|
+
def health(self) -> HealthSnapshot:
|
|
242
|
+
"""Return held values immediately, including after closure."""
|
|
243
|
+
state = (
|
|
244
|
+
EngineState.CLOSED
|
|
245
|
+
if self._closing
|
|
246
|
+
else EngineState.READY
|
|
247
|
+
if self._loaded
|
|
248
|
+
else EngineState.UNPREPARED
|
|
249
|
+
)
|
|
250
|
+
return HealthSnapshot(
|
|
251
|
+
state=state,
|
|
252
|
+
busy=self._model_lock.locked(),
|
|
253
|
+
last_progress_monotonic_s=self._last_progress_monotonic_s,
|
|
254
|
+
**self._health_details(),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class _EngineStream(AsyncIterator[TranscriptEvent]):
|
|
259
|
+
"""Keep the model operation alive while the consumer pauses between reads."""
|
|
260
|
+
|
|
261
|
+
def __init__(self, engine: STTEngineBase, pcm_chunks: AsyncIterator[bytes]) -> None:
|
|
262
|
+
self._engine = engine
|
|
263
|
+
self._pcm_chunks = pcm_chunks
|
|
264
|
+
self._requests: asyncio.Queue[asyncio.Future[TranscriptEvent]] = asyncio.Queue()
|
|
265
|
+
self._task: asyncio.Task[None] | None = None
|
|
266
|
+
self._pending: asyncio.Future[TranscriptEvent] | None = None
|
|
267
|
+
self._closed = False
|
|
268
|
+
|
|
269
|
+
async def __anext__(self) -> TranscriptEvent:
|
|
270
|
+
future = None
|
|
271
|
+
try:
|
|
272
|
+
if self._closed:
|
|
273
|
+
raise StopAsyncIteration
|
|
274
|
+
self._engine._check_open()
|
|
275
|
+
if self._pending is not None:
|
|
276
|
+
raise EngineStateError("stream already has a pending read")
|
|
277
|
+
if self._task is not None and self._task.done():
|
|
278
|
+
self._task.result()
|
|
279
|
+
self._closed = True
|
|
280
|
+
raise StopAsyncIteration
|
|
281
|
+
future = asyncio.get_running_loop().create_future()
|
|
282
|
+
self._pending = future
|
|
283
|
+
self._requests.put_nowait(future)
|
|
284
|
+
if self._task is None:
|
|
285
|
+
self._task = self._engine._start(self._engine._run_stream(self))
|
|
286
|
+
self._task.add_done_callback(self._completed)
|
|
287
|
+
try:
|
|
288
|
+
return await future
|
|
289
|
+
except asyncio.CancelledError:
|
|
290
|
+
await self.aclose()
|
|
291
|
+
raise
|
|
292
|
+
finally:
|
|
293
|
+
self._pending = None
|
|
294
|
+
finally:
|
|
295
|
+
# Both the Future and the stream's task may hold the raised exception.
|
|
296
|
+
del self, future
|
|
297
|
+
|
|
298
|
+
def _completed(self, task: asyncio.Task[None]) -> None:
|
|
299
|
+
error = (
|
|
300
|
+
EngineClosedError("engine closed during recognition")
|
|
301
|
+
if task.cancelled() and self._engine._closing
|
|
302
|
+
else asyncio.CancelledError()
|
|
303
|
+
if task.cancelled()
|
|
304
|
+
else task.exception()
|
|
305
|
+
)
|
|
306
|
+
if self._engine._closing and not isinstance(error, EngineClosedError):
|
|
307
|
+
closed = EngineClosedError("engine closed during recognition")
|
|
308
|
+
closed.__cause__ = error
|
|
309
|
+
error = closed
|
|
310
|
+
if self._pending is not None and not self._pending.done():
|
|
311
|
+
self._pending.set_exception(error or StopAsyncIteration())
|
|
312
|
+
|
|
313
|
+
async def aclose(self) -> None:
|
|
314
|
+
self._closed = True
|
|
315
|
+
if self._task is not None:
|
|
316
|
+
_cancel(self._task)
|
|
317
|
+
await _finish(self._task)
|