fastapi-rtk 0.2.27__py3-none-any.whl → 1.0.13__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.
Files changed (98) hide show
  1. fastapi_rtk/__init__.py +39 -35
  2. fastapi_rtk/_version.py +1 -0
  3. fastapi_rtk/api/model_rest_api.py +476 -221
  4. fastapi_rtk/auth/auth.py +0 -9
  5. fastapi_rtk/backends/generic/__init__.py +6 -0
  6. fastapi_rtk/backends/generic/column.py +21 -12
  7. fastapi_rtk/backends/generic/db.py +42 -7
  8. fastapi_rtk/backends/generic/filters.py +21 -16
  9. fastapi_rtk/backends/generic/interface.py +14 -8
  10. fastapi_rtk/backends/generic/model.py +19 -11
  11. fastapi_rtk/backends/sqla/__init__.py +1 -0
  12. fastapi_rtk/backends/sqla/db.py +77 -17
  13. fastapi_rtk/backends/sqla/extensions/audit/audit.py +401 -189
  14. fastapi_rtk/backends/sqla/extensions/geoalchemy2/filters.py +15 -12
  15. fastapi_rtk/backends/sqla/filters.py +50 -21
  16. fastapi_rtk/backends/sqla/interface.py +96 -34
  17. fastapi_rtk/backends/sqla/model.py +56 -39
  18. fastapi_rtk/bases/__init__.py +20 -0
  19. fastapi_rtk/bases/db.py +94 -7
  20. fastapi_rtk/bases/file_manager.py +47 -3
  21. fastapi_rtk/bases/filter.py +22 -0
  22. fastapi_rtk/bases/interface.py +49 -5
  23. fastapi_rtk/bases/model.py +3 -0
  24. fastapi_rtk/bases/session.py +2 -0
  25. fastapi_rtk/cli/cli.py +62 -9
  26. fastapi_rtk/cli/commands/__init__.py +23 -0
  27. fastapi_rtk/cli/{db.py → commands/db/__init__.py} +107 -50
  28. fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/env.py +2 -3
  29. fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/env.py +10 -9
  30. fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/script.py.mako +3 -1
  31. fastapi_rtk/cli/{export.py → commands/export.py} +12 -10
  32. fastapi_rtk/cli/{security.py → commands/security.py} +73 -7
  33. fastapi_rtk/cli/commands/translate.py +299 -0
  34. fastapi_rtk/cli/decorators.py +9 -4
  35. fastapi_rtk/cli/utils.py +46 -0
  36. fastapi_rtk/config.py +41 -1
  37. fastapi_rtk/const.py +29 -1
  38. fastapi_rtk/db.py +76 -40
  39. fastapi_rtk/decorators.py +1 -1
  40. fastapi_rtk/dependencies.py +134 -62
  41. fastapi_rtk/exceptions.py +51 -1
  42. fastapi_rtk/fastapi_react_toolkit.py +186 -171
  43. fastapi_rtk/file_managers/file_manager.py +8 -6
  44. fastapi_rtk/file_managers/s3_file_manager.py +69 -33
  45. fastapi_rtk/globals.py +22 -12
  46. fastapi_rtk/lang/__init__.py +3 -0
  47. fastapi_rtk/lang/babel/__init__.py +4 -0
  48. fastapi_rtk/lang/babel/cli.py +40 -0
  49. fastapi_rtk/lang/babel/config.py +17 -0
  50. fastapi_rtk/lang/babel.cfg +1 -0
  51. fastapi_rtk/lang/lazy_text.py +120 -0
  52. fastapi_rtk/lang/messages.pot +238 -0
  53. fastapi_rtk/lang/translations/de/LC_MESSAGES/messages.mo +0 -0
  54. fastapi_rtk/lang/translations/de/LC_MESSAGES/messages.po +248 -0
  55. fastapi_rtk/lang/translations/en/LC_MESSAGES/messages.mo +0 -0
  56. fastapi_rtk/lang/translations/en/LC_MESSAGES/messages.po +244 -0
  57. fastapi_rtk/manager.py +355 -37
  58. fastapi_rtk/mixins.py +12 -0
  59. fastapi_rtk/routers.py +208 -72
  60. fastapi_rtk/schemas.py +142 -39
  61. fastapi_rtk/security/sqla/apis.py +39 -13
  62. fastapi_rtk/security/sqla/models.py +8 -23
  63. fastapi_rtk/security/sqla/security_manager.py +369 -11
  64. fastapi_rtk/setting.py +446 -88
  65. fastapi_rtk/types.py +94 -27
  66. fastapi_rtk/utils/__init__.py +8 -0
  67. fastapi_rtk/utils/async_task_runner.py +286 -61
  68. fastapi_rtk/utils/csv_json_converter.py +243 -40
  69. fastapi_rtk/utils/hooks.py +34 -0
  70. fastapi_rtk/utils/merge_schema.py +3 -3
  71. fastapi_rtk/utils/multiple_async_contexts.py +21 -0
  72. fastapi_rtk/utils/pydantic.py +46 -1
  73. fastapi_rtk/utils/run_utils.py +31 -1
  74. fastapi_rtk/utils/self_dependencies.py +1 -1
  75. fastapi_rtk/utils/use_default_when_none.py +1 -1
  76. fastapi_rtk/version.py +6 -1
  77. fastapi_rtk-1.0.13.dist-info/METADATA +28 -0
  78. fastapi_rtk-1.0.13.dist-info/RECORD +133 -0
  79. {fastapi_rtk-0.2.27.dist-info → fastapi_rtk-1.0.13.dist-info}/WHEEL +1 -2
  80. fastapi_rtk/backends/gremlinpython/__init__.py +0 -108
  81. fastapi_rtk/backends/gremlinpython/column.py +0 -208
  82. fastapi_rtk/backends/gremlinpython/db.py +0 -228
  83. fastapi_rtk/backends/gremlinpython/exceptions.py +0 -34
  84. fastapi_rtk/backends/gremlinpython/filters.py +0 -461
  85. fastapi_rtk/backends/gremlinpython/interface.py +0 -734
  86. fastapi_rtk/backends/gremlinpython/model.py +0 -364
  87. fastapi_rtk/backends/gremlinpython/session.py +0 -23
  88. fastapi_rtk/cli/commands.py +0 -295
  89. fastapi_rtk-0.2.27.dist-info/METADATA +0 -23
  90. fastapi_rtk-0.2.27.dist-info/RECORD +0 -126
  91. fastapi_rtk-0.2.27.dist-info/top_level.txt +0 -1
  92. /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/README +0 -0
  93. /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/alembic.ini.mako +0 -0
  94. /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/script.py.mako +0 -0
  95. /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/README +0 -0
  96. /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/alembic.ini.mako +0 -0
  97. {fastapi_rtk-0.2.27.dist-info → fastapi_rtk-1.0.13.dist-info}/entry_points.txt +0 -0
  98. {fastapi_rtk-0.2.27.dist-info → fastapi_rtk-1.0.13.dist-info}/licenses/LICENSE +0 -0
fastapi_rtk/types.py CHANGED
@@ -1,33 +1,20 @@
1
- import typing
2
-
3
1
  import sqlalchemy.dialects.postgresql as postgresql
4
2
  from sqlalchemy import types as sa_types
5
3
 
6
- __all__ = ["FileColumn", "ImageColumn", "ListColumn", "JSONBListColumn"]
7
-
8
-
9
- class FileColumn(sa_types.TypeDecorator):
10
- """
11
- Extends SQLAlchemy to support and mostly identify a File Column
12
- """
13
-
14
- impl = sa_types.Text
15
- cache_ok = True
16
-
4
+ from .exceptions import FastAPIReactToolkitException
5
+ from .globals import g
6
+ from .utils import lazy
17
7
 
18
- class ImageColumn(sa_types.TypeDecorator):
19
- """
20
- Extends SQLAlchemy to support and mostly identify an Image Column
21
-
22
- """
23
-
24
- impl = sa_types.Text
25
- cache_ok = True
26
-
27
- def __init__(self, thumbnail_size=(20, 20, True), size=(100, 100, True), **kw):
28
- sa_types.TypeDecorator.__init__(self, **kw)
29
- self.thumbnail_size = thumbnail_size
30
- self.size = size
8
+ __all__ = [
9
+ "ListColumn",
10
+ "JSONBListColumn",
11
+ "FileColumn",
12
+ "ImageColumn",
13
+ "FileColumns",
14
+ "ImageColumns",
15
+ "JSONBFileColumns",
16
+ "JSONBImageColumns",
17
+ ]
31
18
 
32
19
 
33
20
  class ListColumn(sa_types.TypeDecorator):
@@ -66,4 +53,84 @@ class JSONBListColumn(ListColumn):
66
53
  impl = postgresql.JSONB
67
54
 
68
55
 
69
- ExportMode = typing.Literal["simplified", "detailed"]
56
+ class BaseFileColumn:
57
+ manager = lazy(lambda: g.file_manager)
58
+ origin = "g.file_manager"
59
+
60
+ def __init__(self, allowed_extensions: list[str] | None = None, *args, **kwargs):
61
+ super().__init__(*args, **kwargs)
62
+ if allowed_extensions is None:
63
+ allowed_extensions = self.manager.allowed_extensions
64
+ else:
65
+ # Check whether all extensions are in the allowed extensions from `g.file_manager`
66
+ if self.manager.allowed_extensions is not None and not all(
67
+ ext in self.manager.allowed_extensions for ext in allowed_extensions
68
+ ):
69
+ raise FastAPIReactToolkitException(
70
+ f"Some extensions are not in the allowed extensions from {self.origin}",
71
+ )
72
+ self.allowed_extensions = allowed_extensions
73
+
74
+
75
+ class FileColumn(BaseFileColumn, sa_types.TypeDecorator):
76
+ """
77
+ Extends SQLAlchemy to support and mostly identify a File Column.
78
+ """
79
+
80
+ impl = sa_types.Text
81
+ cache_ok = True
82
+
83
+
84
+ class ImageColumn(BaseFileColumn, sa_types.TypeDecorator):
85
+ """
86
+ Extends SQLAlchemy to support and mostly identify an Image Column.
87
+ """
88
+
89
+ impl = sa_types.Text
90
+ cache_ok = True
91
+ manager = lazy(lambda: g.image_manager)
92
+ origin = "g.image_manager"
93
+
94
+ def __init__(
95
+ self,
96
+ allowed_extensions: list[str] | None = None,
97
+ thumbnail_size=(20, 20, True),
98
+ size=(100, 100, True),
99
+ *args,
100
+ **kwargs,
101
+ ):
102
+ super().__init__(allowed_extensions, *args, **kwargs)
103
+ self.thumbnail_size = thumbnail_size
104
+ self.size = size
105
+
106
+
107
+ class FileColumns(FileColumn, ListColumn):
108
+ """
109
+ A column that represents a list of files.
110
+ """
111
+
112
+ impl = ListColumn
113
+
114
+
115
+ class ImageColumns(ImageColumn, ListColumn):
116
+ """
117
+ A column that represents a list of images.
118
+ """
119
+
120
+ impl = ListColumn
121
+
122
+
123
+ class JSONBFileColumns(FileColumn, JSONBListColumn):
124
+ """
125
+ A column that represents a list of files stored as JSONB.
126
+ """
127
+
128
+ impl = JSONBListColumn
129
+
130
+
131
+ class JSONBImageColumns(ImageColumn, JSONBListColumn):
132
+ """
133
+ A column that represents a list of images stored as JSONB.
134
+ """
135
+
136
+ impl = JSONBListColumn
@@ -6,8 +6,10 @@ from .csv_json_converter import *
6
6
  from .deep_merge import *
7
7
  from .extender_mixin import *
8
8
  from .flask_appbuilder_utils import *
9
+ from .hooks import *
9
10
  from .lazy import *
10
11
  from .merge_schema import *
12
+ from .multiple_async_contexts import *
11
13
  from .prettify_dict import *
12
14
  from .pydantic import *
13
15
  from .run_utils import *
@@ -34,22 +36,28 @@ __all__ = [
34
36
  # .flask_appbuilder_utils
35
37
  "uuid_namegen",
36
38
  "secure_filename",
39
+ # .hooks
40
+ "hooks",
37
41
  # .lazy
38
42
  "lazy",
39
43
  "lazy_import",
40
44
  "lazy_self",
41
45
  # .merge_schema
42
46
  "merge_schema",
47
+ # . multiple_async_contexts
48
+ "multiple_async_contexts",
43
49
  # .prettify_dict
44
50
  "prettify_dict",
45
51
  # .pydantic
46
52
  "generate_schema_from_typed_dict",
53
+ "get_pydantic_model_field",
47
54
  # .run_utils
48
55
  "smart_run",
49
56
  "smart_run_sync",
50
57
  "safe_call",
51
58
  "safe_call_sync",
52
59
  "run_coroutine_in_threadpool",
60
+ "run_function_in_threadpool",
53
61
  "call_with_valid_kwargs",
54
62
  # .self_dependencies
55
63
  "SelfDepends",
@@ -1,12 +1,16 @@
1
1
  import asyncio
2
2
  import contextvars
3
+ import functools
3
4
  import inspect
5
+ import traceback as tb
4
6
  import typing
5
7
 
6
8
  from .prettify_dict import prettify_dict
7
9
 
8
10
  __all__ = ["AsyncTaskRunner"]
9
11
 
12
+ T = typing.TypeVar("T")
13
+
10
14
 
11
15
  class CallerInfo(typing.TypedDict):
12
16
  """
@@ -34,26 +38,37 @@ class AsyncTaskException(AsyncTaskRunnerException):
34
38
  self.caller = caller
35
39
 
36
40
 
37
- def wrap_in_async_task_exception(
38
- task: typing.Callable[[], typing.Coroutine | None] | typing.Coroutine,
39
- /,
40
- caller: CallerInfo | None = None,
41
- ):
42
- async def wrapper():
43
- try:
44
- tsk = task
45
- if callable(tsk):
46
- tsk = tsk()
47
- if tsk is None:
48
- return None
49
- return await tsk
50
- except Exception as e:
51
- raise AsyncTaskException(str(e), original_exception=e, caller=caller) from e
41
+ class ResultAccessor(typing.Generic[T]):
42
+ """Helper class that can be awaited OR accessed directly."""
43
+
44
+ def __init__(self, task: "AsyncTask[T]"):
45
+ self._task = task
46
+
47
+ def __await__(self):
48
+ """Allow awaiting: await task.result"""
49
+ return self._task.__await__()
50
+
51
+ def __repr__(self) -> str:
52
+ """Direct access without await - returns value or raises error."""
53
+ return repr(self._ensure_result())
52
54
 
53
- return wrapper
55
+ def __str__(self) -> str:
56
+ """Direct access without await - returns value or raises error."""
57
+ return str(self._ensure_result())
54
58
 
59
+ # Make it behave like the actual value when accessed
60
+ def __getattr__(self, name):
61
+ return getattr(self._ensure_result(), name)
55
62
 
56
- class AsyncTask:
63
+ def _ensure_result(self):
64
+ if not hasattr(self._task, "_result"):
65
+ raise RuntimeError(
66
+ "Task has not been executed yet. Await the task to get the result."
67
+ )
68
+ return self._task._result
69
+
70
+
71
+ class AsyncTask(typing.Generic[T]):
57
72
  """
58
73
  Represents a task to be run asynchronously.
59
74
 
@@ -62,19 +77,51 @@ class AsyncTask:
62
77
 
63
78
  def __init__(
64
79
  self,
65
- task: typing.Callable[[], typing.Coroutine | None] | typing.Coroutine,
80
+ task: typing.Callable[..., typing.Awaitable[T]] | typing.Awaitable[T],
66
81
  /,
67
82
  caller: CallerInfo | None = None,
68
83
  tags: list[str] | None = None,
69
84
  ):
70
- self.task = wrap_in_async_task_exception(task, caller=caller)
85
+ self.task = task
71
86
  self.caller = caller
72
87
  self.tags = tags or []
73
88
 
89
+ @property
90
+ def result(self):
91
+ """
92
+ Provides access to the result of the task.
93
+
94
+ Returns:
95
+ ResultAccessor[T]: An accessor that can be awaited or accessed directly.
96
+ """
97
+ return ResultAccessor(self)
98
+
74
99
  def __call__(self):
75
- if callable(self.task):
76
- return self.task()
77
- return self.task
100
+ return self._run()
101
+
102
+ def __await__(self):
103
+ return self._run().__await__()
104
+
105
+ async def _run(self):
106
+ """
107
+ Runs the task and caches the result.
108
+
109
+ Returns:
110
+ T: The result of the task.
111
+ """
112
+ if hasattr(self, "_result"):
113
+ return self._result
114
+
115
+ try:
116
+ coro = self.task
117
+ if callable(coro):
118
+ coro = coro()
119
+ self._result = await coro
120
+ return self._result
121
+ except Exception as e:
122
+ raise AsyncTaskException(
123
+ str(e), original_exception=e, caller=self.caller
124
+ ) from e
78
125
 
79
126
 
80
127
  class AsyncTaskRunner:
@@ -129,6 +176,9 @@ class AsyncTaskRunner:
129
176
  _task_stack: contextvars.ContextVar[typing.Optional[list[list[AsyncTask]]]] = (
130
177
  contextvars.ContextVar("_task_stack", default=None)
131
178
  )
179
+ _runner_stack: contextvars.ContextVar[typing.Optional[list["AsyncTaskRunner"]]] = (
180
+ contextvars.ContextVar("_runner_stack", default=None)
181
+ )
132
182
 
133
183
  def __init__(self, run_tasks_even_if_exception=False):
134
184
  """
@@ -139,23 +189,59 @@ class AsyncTaskRunner:
139
189
  """
140
190
  self.run_tasks_even_if_exception = run_tasks_even_if_exception
141
191
 
142
- @staticmethod
192
+ @typing.overload
193
+ @classmethod
143
194
  def add_task(
144
- *tasks: typing.Callable[[], typing.Coroutine | None] | typing.Coroutine,
195
+ cls,
196
+ task: typing.Callable[..., typing.Awaitable[T]]
197
+ | typing.Awaitable[T]
198
+ | AsyncTask[T],
199
+ *,
145
200
  tags: list[str] | None = None,
201
+ instance: "AsyncTaskRunner | None" = None,
202
+ ) -> AsyncTask[T]: ...
203
+
204
+ @typing.overload
205
+ @classmethod
206
+ def add_task(
207
+ cls,
208
+ task1: typing.Callable[..., typing.Awaitable[T]]
209
+ | typing.Awaitable[T]
210
+ | AsyncTask[T],
211
+ task2: typing.Callable[..., typing.Awaitable[T]]
212
+ | typing.Awaitable[T]
213
+ | AsyncTask[T],
214
+ *tasks: typing.Callable[..., typing.Awaitable[T]]
215
+ | typing.Awaitable[T]
216
+ | AsyncTask[T],
217
+ tags: list[str] | None = None,
218
+ instance: "AsyncTaskRunner | None" = None,
219
+ ) -> list[AsyncTask[T]]: ...
220
+ @classmethod
221
+ def add_task(
222
+ cls,
223
+ *tasks: typing.Callable[..., typing.Awaitable[T]]
224
+ | typing.Awaitable[T]
225
+ | AsyncTask[T],
226
+ tags: list[str] | None = None,
227
+ instance: "AsyncTaskRunner | None" = None,
146
228
  ):
147
- stack = AsyncTaskRunner._task_stack.get()
148
- if not stack:
149
- raise RuntimeError(
150
- "AsyncTaskRunner.add_task() called outside of context. "
151
- "Use `async with AsyncTaskRunner():` before calling this."
152
- )
229
+ """
230
+ Adds one or more tasks to the current context's task list. The tasks will be executed when exiting the context.
231
+
232
+ Args:
233
+ tags (list[str] | None, optional): Tags to associate with the tasks. Defaults to None.
234
+ instance (AsyncTaskRunner | None, optional): The AsyncTaskRunner instance to add tasks to. Defaults to None.
153
235
 
236
+ Returns:
237
+ AsyncTask[T] | list[AsyncTask[T]]: The added task(s).
238
+ """
239
+ task_list = cls._get_current_task_list("add_task", instance=instance)
154
240
  # Get caller info
155
241
  frame = inspect.currentframe()
156
242
  caller = inspect.getouterframes(frame, 2)[1] if frame else None
157
243
 
158
- async_tasks = [
244
+ async_tasks: list[AsyncTask[T]] = [
159
245
  AsyncTask(
160
246
  task,
161
247
  caller=CallerInfo(
@@ -169,58 +255,62 @@ class AsyncTaskRunner:
169
255
  else task
170
256
  for task in tasks
171
257
  ]
172
- stack[-1].extend(async_tasks) # Add to the top context
258
+ task_list.extend(async_tasks)
259
+ return async_tasks if len(async_tasks) > 1 else async_tasks[0]
173
260
 
174
- @staticmethod
175
- def remove_tasks_by_tag(tag: str):
261
+ @classmethod
262
+ def remove_tasks_by_tag(
263
+ cls, tag: str, *, instance: "AsyncTaskRunner | None" = None
264
+ ):
176
265
  """
177
266
  Removes tasks with the specified tag from the current context's task list.
178
267
 
179
268
  Args:
180
269
  tag (str): The tag to filter tasks by.
181
270
  """
182
- stack = AsyncTaskRunner._task_stack.get()
183
- if not stack or not stack[-1]:
184
- return
271
+ task_list = cls._get_current_task_list("remove_tasks_by_tag", instance=instance)
272
+ cls._update_current_task_list(
273
+ [task for task in task_list if tag not in task.tags], instance=instance
274
+ )
185
275
 
186
- # Filter tasks in the current context's task list
187
- stack[-1] = [task for task in stack[-1] if tag not in task.tags]
276
+ @classmethod
277
+ def get_current_runner(cls):
278
+ """
279
+ Retrieves the current AsyncTaskRunner instance from the context stack.
188
280
 
189
- async def __aenter__(self):
190
- stack = self._task_stack.get()
191
- if stack is None:
192
- stack = []
193
- self._token = self._task_stack.set(stack)
194
- else:
195
- self._token = None # Only reset when we set a new stack
281
+ Raises:
282
+ RuntimeError: If called outside of a context.
283
+
284
+ Returns:
285
+ AsyncTaskRunner: The current AsyncTaskRunner instance.
286
+ """
287
+ stack = cls._get_current_runner_stack("get_current_runner")
288
+ return stack[-1]
196
289
 
197
- stack.append([]) # Push a new task list for this context
290
+ async def __aenter__(self):
291
+ self._handle_enter_stack()
198
292
  return self
199
293
 
200
294
  async def __aexit__(self, exc_type, exc_value, traceback):
201
- stack = self._task_stack.get()
202
- if not stack:
203
- raise RuntimeError("Task stack corrupted or not initialized.")
204
-
205
- tasks = stack.pop() # Pop the current context's task list
206
-
207
- if self._token:
208
- self._task_stack.reset(self._token)
295
+ stack = self._get_current_stack("__aexit__")
296
+ tasks = self._task_list
297
+ stack.pop() # Remove the current context's task list
298
+ self._handle_exit_stack()
209
299
 
210
300
  if exc_type and not self.run_tasks_even_if_exception:
211
301
  return
212
302
 
213
303
  if tasks:
214
- exceptions: list[AsyncTaskException] = []
304
+ exceptions_with_index = list[tuple[AsyncTaskException, int]]()
215
305
  futures = await asyncio.gather(
216
306
  *(task() for task in tasks), return_exceptions=True
217
307
  )
218
- for future in futures:
308
+ for index, future in enumerate(futures):
219
309
  if isinstance(future, AsyncTaskException):
220
310
  # Handle exceptions from tasks
221
- exceptions.append(future)
311
+ exceptions_with_index.append((future, index))
222
312
 
223
- if exceptions:
313
+ if exceptions_with_index:
224
314
  raise AsyncTaskRunnerException(
225
315
  f"\n{
226
316
  prettify_dict(
@@ -230,11 +320,146 @@ class AsyncTaskRunner:
230
320
  f'Task {index + 1}': {
231
321
  'message': str(exc),
232
322
  'caller': exc.caller,
233
- 'traceback': exc.original_exception.__traceback__,
323
+ 'traceback': ''.join(
324
+ tb.format_exception(exc.original_exception)
325
+ ),
234
326
  }
235
- for index, exc in enumerate(exceptions)
327
+ for exc, index in exceptions_with_index
236
328
  },
237
329
  }
238
330
  )
239
331
  }"
240
332
  )
333
+
334
+ @classmethod
335
+ def _get_current_task_list(
336
+ cls, func_name: str, *, instance: "AsyncTaskRunner | None" = None
337
+ ):
338
+ """
339
+ Retrieves the current task list from the context stack. If an instance is provided, it returns the task list from that instance.
340
+
341
+ Args:
342
+ func_name (str): The name of the function requesting the task list.
343
+ instance (AsyncTaskRunner | None, optional): The instance to retrieve the task list from. Defaults to None.
344
+
345
+ Raises:
346
+ RuntimeError: If called outside of a context.
347
+
348
+ Returns:
349
+ list[AsyncTask]: The current task list.
350
+ """
351
+ if instance:
352
+ return instance._task_list
353
+ return cls._get_current_stack(func_name)[-1]
354
+
355
+ @classmethod
356
+ def _update_current_task_list(
357
+ cls,
358
+ new_task_list: list[AsyncTask],
359
+ *,
360
+ instance: "AsyncTaskRunner | None" = None,
361
+ ):
362
+ """
363
+ Updates the current task list in the context stack. If an instance is provided, it updates the task list in that instance.
364
+
365
+ Args:
366
+ new_task_list (list[AsyncTask]): The new task list to set.
367
+ instance (AsyncTaskRunner | None, optional): The instance to update the task list in. Defaults to None.
368
+
369
+ Raises:
370
+ RuntimeError: If called outside of a context.
371
+ """
372
+ task_list = cls._get_current_task_list(
373
+ "update_current_task_list", instance=instance
374
+ )
375
+ task_list.clear()
376
+ task_list.extend(new_task_list)
377
+
378
+ @classmethod
379
+ def _get_current_stack(cls, func_name: str):
380
+ """
381
+ Retrieves the current task stack from the context variable.
382
+
383
+ Args:
384
+ func_name (str): The name of the function requesting the task stack.
385
+
386
+ Raises:
387
+ RuntimeError: If called outside of a context.
388
+
389
+ Returns:
390
+ list[list[AsyncTask]]: The current task stack.
391
+ """
392
+ stack = cls._task_stack.get()
393
+ if stack is None:
394
+ raise RuntimeError(
395
+ f"{cls.__name__}.{func_name}() called outside of context. "
396
+ f"Use `async with {cls.__name__}():` before calling this."
397
+ )
398
+ return stack
399
+
400
+ @classmethod
401
+ def _get_current_runner_stack(cls, func_name: str):
402
+ """
403
+ Retrieves the current runner stack from the context variable.
404
+
405
+ Args:
406
+ func_name (str): The name of the function requesting the runner stack.
407
+
408
+ Raises:
409
+ RuntimeError: If called outside of a context.
410
+
411
+ Returns:
412
+ list[AsyncTaskRunner]: The current runner stack.
413
+ """
414
+ stack = cls._runner_stack.get()
415
+ if stack is None:
416
+ raise RuntimeError(
417
+ f"{cls.__name__}.{func_name}() called outside of context. "
418
+ f"Use `async with {cls.__name__}():` before calling this."
419
+ )
420
+ return stack
421
+
422
+ def _handle_enter_stack(self):
423
+ """
424
+ Handles the entry into a new context by initializing or updating the task and runner stacks.
425
+ """
426
+ self._handle_init_stack()
427
+ stack, runner_stack = (
428
+ self._get_current_stack(""),
429
+ self._get_current_runner_stack(""),
430
+ )
431
+ self._task_list = list[AsyncTask]()
432
+ stack.append(self._task_list) # Push a new task list for this context
433
+ runner_stack.append(self) # Push this instance to the runner stack
434
+
435
+ # Modify instance methods to bind self
436
+ self.add_task = functools.partial(self.__class__.add_task, instance=self)
437
+ self.remove_tasks_by_tag = functools.partial(
438
+ self.__class__.remove_tasks_by_tag, instance=self
439
+ )
440
+
441
+ def _handle_exit_stack(self):
442
+ """
443
+ Cleans up the context variables for task and runner stacks if they were initialized by this instance.
444
+ """
445
+ if self._token:
446
+ self._task_stack.reset(self._token)
447
+ self._token = None
448
+ if self._runner_token:
449
+ self._runner_stack.reset(self._runner_token)
450
+ self._runner_token = None
451
+
452
+ def _handle_init_stack(self):
453
+ """
454
+ Ensures that the context variables for task and runner stacks are initialized.
455
+ """
456
+ try:
457
+ self._get_current_stack("")
458
+ self._token = None # Only reset when we set a new stack
459
+ except RuntimeError:
460
+ self._token = self._task_stack.set(list[list[AsyncTask]]())
461
+ try:
462
+ self._get_current_runner_stack("")
463
+ self._runner_token = None # Only reset when we set a new stack
464
+ except RuntimeError:
465
+ self._runner_token = self._runner_stack.set(list["AsyncTaskRunner"]())