thds.core 1.37.20250604193856__py3-none-any.whl → 1.37.20250609172634__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.
thds/core/scope.py CHANGED
@@ -37,11 +37,13 @@ wrapping function, which is higher than a `with` statement.
37
37
 
38
38
  """
39
39
 
40
+ import asyncio
40
41
  import atexit
41
42
  import contextlib
42
43
  import inspect
43
44
  import sys
44
45
  import typing as ty
46
+ from collections import defaultdict
45
47
  from functools import wraps
46
48
  from logging import getLogger
47
49
  from uuid import uuid4
@@ -49,37 +51,59 @@ from uuid import uuid4
49
51
  from .inspect import get_caller_info
50
52
  from .stack_context import StackContext
51
53
 
52
- _KEYED_SCOPE_CONTEXTS: ty.Dict[str, StackContext[contextlib.ExitStack]] = dict()
54
+ K = ty.TypeVar("K")
55
+ V = ty.TypeVar("V")
56
+
57
+
58
+ class _keydefaultdict(defaultdict[K, V]):
59
+ def __init__(self, default_factory: ty.Callable[[K], V], *args, **kwargs):
60
+ super().__init__(default_factory, *args, **kwargs) # type: ignore
61
+ self.key_default_factory = default_factory
62
+
63
+ def __missing__(self, key: K) -> V:
64
+ ret = self[key] = self.key_default_factory(key)
65
+ return ret
66
+
67
+
68
+ _KEYED_SCOPE_CONTEXTS: dict[str, StackContext[contextlib.ExitStack]] = _keydefaultdict(
69
+ lambda key: StackContext(key, contextlib.ExitStack())
70
+ )
71
+ _KEYED_SCOPE_ASYNC_CONTEXTS: dict[str, StackContext[contextlib.AsyncExitStack]] = _keydefaultdict(
72
+ lambda key: StackContext(key, contextlib.AsyncExitStack())
73
+ )
53
74
  # all non-nil ExitStacks will be closed at application exit
54
75
 
55
76
 
56
77
  def _close_root_scopes_atexit():
57
78
  for name, scope_sc in _KEYED_SCOPE_CONTEXTS.items():
58
- scope = scope_sc()
59
- if scope:
79
+ exit_stack_cm = scope_sc()
80
+ if exit_stack_cm:
60
81
  try:
61
- scope.close()
62
- except ValueError as ve:
63
- print(f"Unable to close scope '{name}' at exit because {ve}", file=sys.stderr)
82
+ exit_stack_cm.__exit__(None, None, None)
83
+ except Exception as exc:
84
+ print(f"Unable to exit scope '{name}' at exit because {exc}", file=sys.stderr)
85
+
86
+ async def do_async_cleanup():
87
+ for name, scope_sc in _KEYED_SCOPE_ASYNC_CONTEXTS.items():
88
+ exit_stack_cm = scope_sc()
89
+ if exit_stack_cm:
90
+ try:
91
+ await exit_stack_cm.__aexit__(None, None, None)
92
+ except Exception as exc:
93
+ print(f"Unable to exit async scope '{name}' at exit because {exc}", file=sys.stderr)
94
+
95
+ if _KEYED_SCOPE_ASYNC_CONTEXTS:
96
+ loop = asyncio.new_event_loop()
97
+ asyncio.set_event_loop(loop)
98
+ try:
99
+ loop.run_until_complete(do_async_cleanup())
100
+ finally:
101
+ loop.close()
64
102
 
65
103
 
66
104
  atexit.register(_close_root_scopes_atexit)
67
105
 
68
106
 
69
- def _init_sc(key: str, val: contextlib.ExitStack):
70
- """This should only ever be called at the root of/during import of a
71
- module. It is _not_ threadsafe.
72
- """
73
- # normally you shouldn't create a StackContext except as a
74
- # global. in this case, we're dynamically storing _in_ a
75
- # global dict, which is equivalent.
76
- if key in _KEYED_SCOPE_CONTEXTS:
77
- getLogger(__name__).warning(
78
- f"Scope {key} already exists! If this is not importlib.reload, you have a problem."
79
- )
80
- _KEYED_SCOPE_CONTEXTS[key] = StackContext(key, val)
81
-
82
-
83
107
  F = ty.TypeVar("F", bound=ty.Callable)
84
108
 
85
109
 
@@ -92,9 +116,6 @@ def _bound(key: str, func: F) -> F:
92
116
 
93
117
  @wraps(func)
94
118
  def __scope_boundary_generator_wrap(*args, **kwargs):
95
- if key not in _KEYED_SCOPE_CONTEXTS:
96
- _init_sc(key, contextlib.ExitStack()) # this root stack will probably not get used
97
-
98
119
  with _KEYED_SCOPE_CONTEXTS[key].set(contextlib.ExitStack()) as scoped_exit_stack:
99
120
  with scoped_exit_stack: # enter and exit the ExitStack itself
100
121
  ret = yield from func(*args, **kwargs)
@@ -106,9 +127,6 @@ def _bound(key: str, func: F) -> F:
106
127
 
107
128
  @wraps(func)
108
129
  async def __scope_boundary_async_generator_wrap(*args, **kwargs):
109
- if key not in _KEYED_SCOPE_CONTEXTS:
110
- _init_sc(key, contextlib.ExitStack())
111
-
112
130
  with _KEYED_SCOPE_CONTEXTS[key].set(contextlib.ExitStack()) as scoped_exit_stack:
113
131
  with scoped_exit_stack:
114
132
  async for ret in func(*args, **kwargs):
@@ -120,9 +138,6 @@ def _bound(key: str, func: F) -> F:
120
138
 
121
139
  @wraps(func)
122
140
  async def __scope_boundary_coroutine_wrap(*args, **kwargs):
123
- if key not in _KEYED_SCOPE_CONTEXTS:
124
- _init_sc(key, contextlib.ExitStack())
125
-
126
141
  with _KEYED_SCOPE_CONTEXTS[key].set(contextlib.ExitStack()) as scoped_exit_stack:
127
142
  with scoped_exit_stack:
128
143
  return await func(*args, **kwargs)
@@ -131,9 +146,6 @@ def _bound(key: str, func: F) -> F:
131
146
 
132
147
  @wraps(func)
133
148
  def __scope_boundary_wrap(*args, **kwargs):
134
- if key not in _KEYED_SCOPE_CONTEXTS:
135
- _init_sc(key, contextlib.ExitStack()) # this root stack will probably not get used
136
-
137
149
  with _KEYED_SCOPE_CONTEXTS[key].set(contextlib.ExitStack()) as scoped_exit_stack:
138
150
  with scoped_exit_stack: # enter and exit the ExitStack itself
139
151
  return func(*args, **kwargs)
@@ -141,6 +153,33 @@ def _bound(key: str, func: F) -> F:
141
153
  return ty.cast(F, __scope_boundary_wrap)
142
154
 
143
155
 
156
+ def _async_bound(key: str, func: F) -> F:
157
+ """A decorator that establishes a scope boundary for context managers
158
+ that can now be `aenter`ed, and will then be aexited when this
159
+ boundary is returned to.
160
+ """
161
+ if inspect.isasyncgenfunction(func):
162
+
163
+ @wraps(func)
164
+ async def __scope_boundary_async_generator_wrap(*args, **kwargs):
165
+ with _KEYED_SCOPE_ASYNC_CONTEXTS[key].set(contextlib.AsyncExitStack()) as scoped_exit_stack:
166
+ async with scoped_exit_stack:
167
+ async for ret in func(*args, **kwargs):
168
+ yield ret
169
+
170
+ return ty.cast(F, __scope_boundary_async_generator_wrap)
171
+
172
+ assert inspect.iscoroutinefunction(func), "You should not use async_bound on non-async functions."
173
+
174
+ @wraps(func)
175
+ async def __scope_boundary_coroutine_wrap(*args, **kwargs):
176
+ with _KEYED_SCOPE_ASYNC_CONTEXTS[key].set(contextlib.AsyncExitStack()) as scoped_exit_stack:
177
+ async with scoped_exit_stack:
178
+ return await func(*args, **kwargs)
179
+
180
+ return ty.cast(F, __scope_boundary_coroutine_wrap)
181
+
182
+
144
183
  class NoScopeFound(Exception):
145
184
  pass
146
185
 
@@ -157,7 +196,14 @@ def _enter(key: str, context: ty.ContextManager[M]) -> M:
157
196
  scope_context = _KEYED_SCOPE_CONTEXTS.get(key)
158
197
  if scope_context:
159
198
  return scope_context().enter_context(context)
160
- raise NoScopeFound(f"No scope with the key {key} was found.")
199
+ raise NoScopeFound(f"No scope with the key {key} was found - did you call .bound()?")
200
+
201
+
202
+ async def _async_enter(key: str, context: ty.AsyncContextManager[M]) -> M:
203
+ scope_context = _KEYED_SCOPE_ASYNC_CONTEXTS.get(key)
204
+ if scope_context:
205
+ return await scope_context().enter_async_context(context)
206
+ raise NoScopeFound(f"No async scope with the key {key} was found - did you call .async_bound()?")
161
207
 
162
208
 
163
209
  class Scope:
@@ -181,7 +227,12 @@ class Scope:
181
227
  def __init__(self, key: str = ""):
182
228
  caller_info = get_caller_info(skip=1)
183
229
  self.key = caller_info.module + "+" + (key or uuid4().hex)
184
- _init_sc(self.key, contextlib.ExitStack()) # add root boundary
230
+ if self.key in _KEYED_SCOPE_CONTEXTS:
231
+ getLogger(__name__).warning(
232
+ f"Scope with key '{self.key}' already exists! If this is not importlib.reload, you have a problem."
233
+ )
234
+ else:
235
+ _KEYED_SCOPE_CONTEXTS[self.key] = StackContext(self.key, contextlib.ExitStack())
185
236
 
186
237
  def bound(self, func: F) -> F:
187
238
  """Add a boundary to this function which will close all of the
@@ -194,6 +245,43 @@ class Scope:
194
245
  return _enter(self.key, context)
195
246
 
196
247
 
248
+ class AsyncScope:
249
+ """See docs for Scope - but this is the one you use when you have async context
250
+ managers.
251
+
252
+ These should be module-level/global objects under all
253
+ circumstances, as they share an internal global namespace.
254
+
255
+ """
256
+
257
+ def __init__(self, key: str = ""):
258
+ caller_info = get_caller_info(skip=1)
259
+ self.key = caller_info.module + "+" + (key or uuid4().hex)
260
+ if self.key in _KEYED_SCOPE_ASYNC_CONTEXTS:
261
+ getLogger(__name__).warning(
262
+ f"Async scope with key '{self.key}' already exists! If this is not importlib.reload, you have a problem."
263
+ )
264
+ else:
265
+ _KEYED_SCOPE_ASYNC_CONTEXTS[self.key] = StackContext(self.key, contextlib.AsyncExitStack())
266
+
267
+ def async_bound(self, func: F) -> F:
268
+ """Add an async context management boundary to this function - it will _only_
269
+ use async context managers, i.e. __aenter__ and __aexit__ methods.
270
+
271
+ You can wrap an async function with a synchronous bound and use synchronous
272
+ context managers with it, but there's no point to wrapping your sync function
273
+ with an async_bound because you won't be able to enter the context within it.
274
+ """
275
+ return _async_bound(self.key, func)
276
+
277
+ async def async_enter(self, context: ty.AsyncContextManager[M]) -> M:
278
+ """Enter the provided Context with a future exit at the nearest boundary for this Scope."""
279
+ return await _async_enter(self.key, context)
280
+
281
+
197
282
  default = Scope("__default_scope_stack")
198
283
  bound = default.bound
199
284
  enter = default.enter
285
+ default_async = AsyncScope("__default_async_scope_stack")
286
+ async_bound = default_async.async_bound
287
+ async_enter = default_async.async_enter
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thds.core
3
- Version: 1.37.20250604193856
3
+ Version: 1.37.20250609172634
4
4
  Summary: Core utilities.
5
5
  Author-email: Trilliant Health <info@trillianthealth.com>
6
6
  License: MIT
@@ -33,7 +33,7 @@ thds/core/progress.py,sha256=4YGbxliDl1i-k-88w4s86uy1E69eQ6xJySGPSkpH1QM,3358
33
33
  thds/core/protocols.py,sha256=4na2EeWUDWfLn5-SxfMmKegDSndJ5z-vwMhDavhCpEM,409
34
34
  thds/core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
35
35
  thds/core/scaling.py,sha256=f7CtdgK0sN6nroTq5hLAkG8xwbWhbCZUULstSKjoxO0,1615
36
- thds/core/scope.py,sha256=iPRhS-lIe-axDctqxBtEPeF0PM_w-0tRS-9kPweUGBY,7205
36
+ thds/core/scope.py,sha256=9RWWCFRqsgjTyH6rzRm_WnO69N_sEBRaykarc2PAnBY,10834
37
37
  thds/core/source_serde.py,sha256=X4c7LiT3VidejqtTel9YB6dWGB3x-ct39KF9E50Nbx4,139
38
38
  thds/core/stack_context.py,sha256=17lPOuYWclUpZ-VXRkPgI4WbiMzq7_ZY6Kj1QK_1oNo,1332
39
39
  thds/core/thunks.py,sha256=p1OvMBJ4VGMsD8BVA7zwPeAp0L3y_nxVozBF2E78t3M,1053
@@ -68,8 +68,8 @@ thds/core/sqlite/structured.py,sha256=SvZ67KcVcVdmpR52JSd52vMTW2ALUXmlHEeD-VrzWV
68
68
  thds/core/sqlite/types.py,sha256=oUkfoKRYNGDPZRk29s09rc9ha3SCk2SKr_K6WKebBFs,1308
69
69
  thds/core/sqlite/upsert.py,sha256=BmKK6fsGVedt43iY-Lp7dnAu8aJ1e9CYlPVEQR2pMj4,5827
70
70
  thds/core/sqlite/write.py,sha256=z0219vDkQDCnsV0WLvsj94keItr7H4j7Y_evbcoBrWU,3458
71
- thds_core-1.37.20250604193856.dist-info/METADATA,sha256=OzxYTGp2aINNT1WCTLmqUyob9YFZumzbOw7GOM4glkA,2275
72
- thds_core-1.37.20250604193856.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
73
- thds_core-1.37.20250604193856.dist-info/entry_points.txt,sha256=bOCOVhKZv7azF3FvaWX6uxE6yrjK6FcjqhtxXvLiFY8,161
74
- thds_core-1.37.20250604193856.dist-info/top_level.txt,sha256=LTZaE5SkWJwv9bwOlMbIhiS-JWQEEIcjVYnJrt-CriY,5
75
- thds_core-1.37.20250604193856.dist-info/RECORD,,
71
+ thds_core-1.37.20250609172634.dist-info/METADATA,sha256=jiRLDApPyiMil8ra_W3dbIkBNLvJlksrcFpeeFR08u8,2275
72
+ thds_core-1.37.20250609172634.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
73
+ thds_core-1.37.20250609172634.dist-info/entry_points.txt,sha256=bOCOVhKZv7azF3FvaWX6uxE6yrjK6FcjqhtxXvLiFY8,161
74
+ thds_core-1.37.20250609172634.dist-info/top_level.txt,sha256=LTZaE5SkWJwv9bwOlMbIhiS-JWQEEIcjVYnJrt-CriY,5
75
+ thds_core-1.37.20250609172634.dist-info/RECORD,,