Coverage for Lib/asyncio/taskgroups.py: 97%

161 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-25 03:29 +0000

1# Adapted with permission from the EdgeDB project; 

2# license: PSFL. 

3 

4 

5__all__ = ("TaskGroup",) 

6 

7from . import events 

8from . import exceptions 

9from . import futures 

10from . import tasks 

11 

12 

13class TaskGroup: 

14 """Asynchronous context manager for managing groups of tasks. 

15 

16 Example use: 

17 

18 async with asyncio.TaskGroup() as group: 

19 task1 = group.create_task(some_coroutine(...)) 

20 task2 = group.create_task(other_coroutine(...)) 

21 print("Both tasks have completed now.") 

22 

23 All tasks are awaited when the context manager exits. 

24 

25 Any exceptions other than `asyncio.CancelledError` raised within 

26 a task will cancel all remaining tasks and wait for them to exit. 

27 The exceptions are then combined and raised as an `ExceptionGroup`. 

28 """ 

29 def __init__(self): 

30 self._entered = False 

31 self._exiting = False 

32 self._aborting = False 

33 self._loop = None 

34 self._parent_task = None 

35 self._parent_cancel_requested = False 

36 self._tasks = set() 

37 self._errors = [] 

38 self._base_error = None 

39 self._on_completed_fut = None 

40 self._cancel_on_enter = False 

41 

42 def __repr__(self): 

43 info = [''] 

44 if self._tasks: 

45 info.append(f'tasks={len(self._tasks)}') 

46 if self._errors: 

47 info.append(f'errors={len(self._errors)}') 

48 if self._aborting: 

49 info.append('cancelling') 

50 elif self._entered: 

51 info.append('entered') 

52 

53 info_str = ' '.join(info) 

54 return f'<TaskGroup{info_str}>' 

55 

56 async def __aenter__(self): 

57 if self._entered: 

58 raise RuntimeError( 

59 f"TaskGroup {self!r} has already been entered") 

60 if self._loop is None: 60 ↛ 62line 60 didn't jump to line 62 because the condition on line 60 was always true

61 self._loop = events.get_running_loop() 

62 self._parent_task = tasks.current_task(self._loop) 

63 if self._parent_task is None: 

64 raise RuntimeError( 

65 f'TaskGroup {self!r} cannot determine the parent task') 

66 self._entered = True 

67 if self._cancel_on_enter: 

68 self.cancel() 

69 

70 return self 

71 

72 async def __aexit__(self, et, exc, tb): 

73 tb = None 

74 try: 

75 return await self._aexit(et, exc) 

76 finally: 

77 # Exceptions are heavy objects that can have object 

78 # cycles (bad for GC); let's not keep a reference to 

79 # a bunch of them. It would be nicer to use a try/finally 

80 # in __aexit__ directly but that introduced some diff noise 

81 self._parent_task = None 

82 self._errors = None 

83 self._base_error = None 

84 exc = None 

85 

86 async def _aexit(self, et, exc): 

87 self._exiting = True 

88 

89 if (exc is not None and 

90 self._is_base_error(exc) and 

91 self._base_error is None): 

92 self._base_error = exc 

93 

94 if et is not None and issubclass(et, exceptions.CancelledError): 

95 propagate_cancellation_error = exc 

96 else: 

97 propagate_cancellation_error = None 

98 

99 if et is not None: 

100 if not self._aborting: 

101 # Our parent task is being cancelled: 

102 # 

103 # async with TaskGroup() as g: 

104 # g.create_task(...) 

105 # await ... # <- CancelledError 

106 # 

107 # or there's an exception in "async with": 

108 # 

109 # async with TaskGroup() as g: 

110 # g.create_task(...) 

111 # 1 / 0 

112 # 

113 self._abort() 

114 

115 # We use while-loop here because "self._on_completed_fut" 

116 # can be cancelled multiple times if our parent task 

117 # is being cancelled repeatedly (or even once, when 

118 # our own cancellation is already in progress) 

119 pending_cancellation_error = None 

120 while self._tasks: 

121 if self._on_completed_fut is None: 121 ↛ 124line 121 didn't jump to line 124 because the condition on line 121 was always true

122 self._on_completed_fut = self._loop.create_future() 

123 

124 try: 

125 await self._on_completed_fut 

126 except exceptions.CancelledError as ex: 

127 pending_cancellation_error = ex 

128 if not self._aborting: 

129 # Our parent task is being cancelled: 

130 # 

131 # async def wrapper(): 

132 # async with TaskGroup() as g: 

133 # g.create_task(foo) 

134 # 

135 # "wrapper" is being cancelled while "foo" is 

136 # still running. 

137 propagate_cancellation_error = ex 

138 self._abort() 

139 

140 self._on_completed_fut = None 

141 

142 assert not self._tasks 

143 

144 if self._base_error is not None: 

145 # self._base_error (SystemExit or KeyboardInterrupt) is about 

146 # to propagate out of this method, which discards any other 

147 # collected task errors silently. Report them instead of 

148 # losing them. See gh-135736. 

149 for suppressed_exc in self._errors: 

150 self._loop.call_exception_handler({ 

151 'message': 'TaskGroup task exception was not ' 

152 'propagated because the TaskGroup body ' 

153 'is being closed with a BaseException', 

154 'exception': suppressed_exc, 

155 'task_group': self, 

156 }) 

157 try: 

158 raise self._base_error 

159 finally: 

160 exc = None 

161 

162 if self._parent_cancel_requested: 

163 # If this flag is set we *must* call uncancel(). 

164 if self._parent_task.uncancel() == 0: 

165 # If there are no pending cancellations left, 

166 # don't propagate CancelledError. 

167 propagate_cancellation_error = None 

168 elif propagate_cancellation_error is None: 

169 # gh-155433: the remaining cancellation is not ours, don't drop it 

170 propagate_cancellation_error = pending_cancellation_error 

171 

172 # Propagate CancelledError if there is one, except if there 

173 # are other errors -- those have priority. 

174 try: 

175 if propagate_cancellation_error is not None and not self._errors: 

176 try: 

177 raise propagate_cancellation_error 

178 finally: 

179 exc = None 

180 finally: 

181 propagate_cancellation_error = None 

182 

183 if et is not None and not issubclass(et, exceptions.CancelledError): 

184 self._errors.append(exc) 

185 

186 if self._errors: 

187 # If the parent task is being cancelled from the outside 

188 # of the taskgroup, un-cancel and re-cancel the parent task, 

189 # which will keep the cancel count stable. 

190 if self._parent_task.cancelling(): 

191 self._parent_task.uncancel() 

192 self._parent_task.cancel() 

193 try: 

194 # If the *only* error is a GeneratorExit from the body 

195 # of the group, then instead of raising an 

196 # ExceptionGroup we raise GeneratorExit. This ensures 

197 # that async generators that use TaskGroup properly 

198 # swallow the exception on `aclose()` while ensuring 

199 # that no exceptions from subtasks are swallowed. 

200 if ( 

201 et is not None 

202 and issubclass(et, GeneratorExit) 

203 and len(self._errors) == 1 

204 ): 

205 raise exc 

206 else: 

207 raise BaseExceptionGroup( 

208 'unhandled errors in a TaskGroup', 

209 self._errors, 

210 ) from None 

211 finally: 

212 exc = None 

213 

214 # Suppress any remaining exception (exceptions deserving to be raised 

215 # were raised above). 

216 return True 

217 

218 def create_task(self, coro, **kwargs): 

219 """Create a new task in this group and return it. 

220 

221 Similar to `asyncio.create_task`. 

222 """ 

223 if not self._entered: 

224 coro.close() 

225 raise RuntimeError(f"TaskGroup {self!r} has not been entered") 

226 if self._exiting and not self._tasks: 

227 coro.close() 

228 raise RuntimeError(f"TaskGroup {self!r} is finished") 

229 if self._aborting: 

230 coro.close() 

231 raise RuntimeError(f"TaskGroup {self!r} is shutting down") 

232 task = self._loop.create_task(coro, **kwargs) 

233 

234 futures.future_add_to_awaited_by(task, self._parent_task) 

235 

236 # Always schedule the done callback even if the task is 

237 # already done (e.g. if the coro was able to complete eagerly), 

238 # otherwise if the task completes with an exception then it will cancel 

239 # the current task too early. gh-128550, gh-128588 

240 self._tasks.add(task) 

241 task.add_done_callback(self._on_task_done) 

242 # gh-155418: an eager task can cancel the group before joining _tasks 

243 if self._aborting and not task.done(): 

244 task.cancel() 

245 try: 

246 return task 

247 finally: 

248 # gh-128552: prevent a refcycle of 

249 # task.exception().__traceback__->TaskGroup.create_task->task 

250 del task 

251 

252 # Since Python 3.8 Tasks propagate all exceptions correctly, 

253 # except for KeyboardInterrupt and SystemExit which are 

254 # still considered special. 

255 

256 def _is_base_error(self, exc: BaseException) -> bool: 

257 assert isinstance(exc, BaseException) 

258 return isinstance(exc, (SystemExit, KeyboardInterrupt)) 

259 

260 def _abort(self): 

261 self._aborting = True 

262 

263 for t in self._tasks: 

264 if not t.done(): 

265 t.cancel() 

266 

267 def _on_task_done(self, task): 

268 self._tasks.discard(task) 

269 

270 futures.future_discard_from_awaited_by(task, self._parent_task) 

271 

272 if self._on_completed_fut is not None and not self._tasks: 

273 if not self._on_completed_fut.done(): 

274 self._on_completed_fut.set_result(True) 

275 

276 if task.cancelled(): 

277 return 

278 

279 exc = task.exception() 

280 if exc is None: 

281 return 

282 

283 self._errors.append(exc) 

284 if self._is_base_error(exc) and self._base_error is None: 284 ↛ 285line 284 didn't jump to line 285 because the condition on line 284 was never true

285 self._base_error = exc 

286 

287 if self._parent_task.done(): 287 ↛ 290line 287 didn't jump to line 290 because the condition on line 287 was never true

288 # Not sure if this case is possible, but we want to handle 

289 # it anyways. 

290 self._loop.call_exception_handler({ 

291 'message': f'Task {task!r} has errored out but its parent ' 

292 f'task {self._parent_task} is already completed', 

293 'exception': exc, 

294 'task': task, 

295 }) 

296 return 

297 

298 if not self._aborting and not self._parent_cancel_requested: 

299 # If parent task *is not* being cancelled, it means that we want 

300 # to manually cancel it to abort whatever is being run right now 

301 # in the TaskGroup. But we want to mark parent task as 

302 # "not cancelled" later in __aexit__. Example situation that 

303 # we need to handle: 

304 # 

305 # async def foo(): 

306 # try: 

307 # async with TaskGroup() as g: 

308 # g.create_task(crash_soon()) 

309 # await something # <- this needs to be canceled 

310 # # by the TaskGroup, e.g. 

311 # # foo() needs to be cancelled 

312 # except Exception: 

313 # # Ignore any exceptions raised in the TaskGroup 

314 # pass 

315 # await something_else # this line has to be called 

316 # # after TaskGroup is finished. 

317 self._abort() 

318 self._parent_cancel_requested = True 

319 self._parent_task.cancel() 

320 

321 def cancel(self): 

322 """Cancel the task group 

323 

324 `cancel()` will be called on any tasks in the group that aren't yet 

325 done, as well as the parent (body) of the group. This will cause 

326 the task group context manager to exit *without* 

327 `asyncio.CancelledError` being raised. 

328 

329 If `cancel()` is called before entering the task group, the group 

330 will be cancelled upon entry. This is useful for patterns where 

331 one piece of code passes an unused TaskGroup instance to another in 

332 order to have the ability to cancel anything run within the group. 

333 

334 `cancel()` is idempotent and may be called after the task group has 

335 already exited. 

336 """ 

337 if not self._entered: 

338 self._cancel_on_enter = True 

339 return 

340 if self._exiting and not self._tasks: 

341 return 

342 if not self._aborting: 

343 self._abort() 

344 if self._parent_task and not self._parent_cancel_requested: 344 ↛ exitline 344 didn't return from function 'cancel' because the condition on line 344 was always true

345 self._parent_cancel_requested = True 

346 self._parent_task.cancel()