haiway 0.1.0__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.
@@ -0,0 +1,226 @@
1
+ from asyncio import AbstractEventLoop, get_running_loop, iscoroutinefunction
2
+ from collections.abc import Callable, Coroutine
3
+ from concurrent.futures import Executor
4
+ from contextvars import Context, copy_context
5
+ from functools import partial
6
+ from typing import Any, Literal, cast, overload
7
+
8
+ from haiway.types.missing import MISSING, Missing, not_missing
9
+
10
+ __all__ = [
11
+ "asynchronous",
12
+ ]
13
+
14
+
15
+ @overload
16
+ def asynchronous[**Args, Result]() -> (
17
+ Callable[
18
+ [Callable[Args, Result]],
19
+ Callable[Args, Coroutine[None, None, Result]],
20
+ ]
21
+ ): ...
22
+
23
+
24
+ @overload
25
+ def asynchronous[**Args, Result](
26
+ *,
27
+ loop: AbstractEventLoop | None = None,
28
+ executor: Executor | Literal["default"],
29
+ ) -> Callable[
30
+ [Callable[Args, Result]],
31
+ Callable[Args, Coroutine[None, None, Result]],
32
+ ]: ...
33
+
34
+
35
+ @overload
36
+ def asynchronous[**Args, Result](
37
+ function: Callable[Args, Result],
38
+ /,
39
+ ) -> Callable[Args, Coroutine[None, None, Result]]: ...
40
+
41
+
42
+ def asynchronous[**Args, Result](
43
+ function: Callable[Args, Result] | None = None,
44
+ /,
45
+ loop: AbstractEventLoop | None = None,
46
+ executor: Executor | Literal["default"] | Missing = MISSING,
47
+ ) -> (
48
+ Callable[
49
+ [Callable[Args, Result]],
50
+ Callable[Args, Coroutine[None, None, Result]],
51
+ ]
52
+ | Callable[Args, Coroutine[None, None, Result]]
53
+ ):
54
+ """\
55
+ Wrapper for a sync function to convert it to an async function. \
56
+ When specified an executor, it can be used to wrap long running or blocking synchronous \
57
+ operations within coroutines system.
58
+
59
+ Parameters
60
+ ----------
61
+ function: Callable[Args, Result]
62
+ function to be wrapped as running in loop executor.
63
+ loop: AbstractEventLoop | None
64
+ loop used to call the function. When None was provided the loop currently running while \
65
+ executing the function will be used. Default is None.
66
+ executor: Executor | Literal["default"] | Missing
67
+ executor used to run the function. Specifying "default" uses a default loop executor.
68
+ When not provided (Missing) no executor will be used \
69
+ (function will by just wrapped as an async function without any executor)
70
+
71
+ Returns
72
+ -------
73
+ Callable[_Args, _Result]
74
+ function wrapped to async using loop executor.
75
+ """
76
+
77
+ def wrap(
78
+ wrapped: Callable[Args, Result],
79
+ ) -> Callable[Args, Coroutine[None, None, Result]]:
80
+ assert not iscoroutinefunction(wrapped), "Cannot wrap async function in executor" # nosec: B101
81
+
82
+ if not_missing(executor):
83
+ return _ExecutorWrapper(
84
+ wrapped,
85
+ loop=loop,
86
+ executor=cast(Executor | None, None if executor == "default" else executor),
87
+ )
88
+
89
+ else:
90
+
91
+ async def wrapper(
92
+ *args: Args.args,
93
+ **kwargs: Args.kwargs,
94
+ ) -> Result:
95
+ return wrapped(
96
+ *args,
97
+ **kwargs,
98
+ )
99
+
100
+ _mimic_async(wrapped, within=wrapper)
101
+ return wrapper
102
+
103
+ if function := function:
104
+ return wrap(wrapped=function)
105
+
106
+ else:
107
+ return wrap
108
+
109
+
110
+ class _ExecutorWrapper[**Args, Result]:
111
+ def __init__(
112
+ self,
113
+ function: Callable[Args, Result],
114
+ /,
115
+ loop: AbstractEventLoop | None,
116
+ executor: Executor | None,
117
+ ) -> None:
118
+ self._function: Callable[Args, Result] = function
119
+ self._loop: AbstractEventLoop | None = loop
120
+ self._executor: Executor | None = executor
121
+
122
+ # mimic function attributes if able
123
+ _mimic_async(function, within=self)
124
+
125
+ async def __call__(
126
+ self,
127
+ *args: Args.args,
128
+ **kwargs: Args.kwargs,
129
+ ) -> Result:
130
+ context: Context = copy_context()
131
+ return await (self._loop or get_running_loop()).run_in_executor(
132
+ self._executor,
133
+ context.run,
134
+ partial(self._function, *args, **kwargs),
135
+ )
136
+
137
+ def __get__(
138
+ self,
139
+ instance: object,
140
+ owner: type | None = None,
141
+ /,
142
+ ) -> Callable[Args, Coroutine[None, None, Result]]:
143
+ if owner is None:
144
+ return self
145
+
146
+ else:
147
+ return _mimic_async(
148
+ self._function,
149
+ within=partial(
150
+ self.__method_call__,
151
+ instance,
152
+ ),
153
+ )
154
+
155
+ async def __method_call__(
156
+ self,
157
+ __method_self: object,
158
+ *args: Args.args,
159
+ **kwargs: Args.kwargs,
160
+ ) -> Result:
161
+ return await (self._loop or get_running_loop()).run_in_executor(
162
+ self._executor,
163
+ partial(self._function, __method_self, *args, **kwargs),
164
+ )
165
+
166
+
167
+ def _mimic_async[**Args, Result](
168
+ function: Callable[Args, Result],
169
+ /,
170
+ within: Callable[..., Coroutine[None, None, Result]],
171
+ ) -> Callable[Args, Coroutine[None, None, Result]]:
172
+ try:
173
+ annotations: Any = getattr( # noqa: B009
174
+ function,
175
+ "__annotations__",
176
+ )
177
+ setattr( # noqa: B010
178
+ within,
179
+ "__annotations__",
180
+ {
181
+ **annotations,
182
+ "return": Coroutine[None, None, annotations.get("return", Any)],
183
+ },
184
+ )
185
+
186
+ except AttributeError:
187
+ pass
188
+
189
+ for attribute in (
190
+ "__module__",
191
+ "__name__",
192
+ "__qualname__",
193
+ "__doc__",
194
+ "__type_params__",
195
+ "__defaults__",
196
+ "__kwdefaults__",
197
+ "__globals__",
198
+ ):
199
+ try:
200
+ setattr(
201
+ within,
202
+ attribute,
203
+ getattr(
204
+ function,
205
+ attribute,
206
+ ),
207
+ )
208
+
209
+ except AttributeError:
210
+ pass
211
+ try:
212
+ within.__dict__.update(function.__dict__)
213
+
214
+ except AttributeError:
215
+ pass
216
+
217
+ setattr( # noqa: B010 - mimic functools.wraps behavior for correct signature checks
218
+ within,
219
+ "__wrapped__",
220
+ function,
221
+ )
222
+
223
+ return cast(
224
+ Callable[Args, Coroutine[None, None, Result]],
225
+ within,
226
+ )
@@ -0,0 +1,326 @@
1
+ from asyncio import AbstractEventLoop, Task, get_running_loop, iscoroutinefunction, shield
2
+ from collections import OrderedDict
3
+ from collections.abc import Callable, Coroutine, Hashable
4
+ from functools import _make_key, partial # pyright: ignore[reportPrivateUsage]
5
+ from time import monotonic
6
+ from typing import NamedTuple, cast, overload
7
+ from weakref import ref
8
+
9
+ from haiway.utils.mimic import mimic_function
10
+
11
+ __all__ = [
12
+ "cached",
13
+ ]
14
+
15
+
16
+ @overload
17
+ def cached[**Args, Result](
18
+ function: Callable[Args, Result],
19
+ /,
20
+ ) -> Callable[Args, Result]: ...
21
+
22
+
23
+ @overload
24
+ def cached[**Args, Result](
25
+ *,
26
+ limit: int = 1,
27
+ expiration: float | None = None,
28
+ ) -> Callable[[Callable[Args, Result]], Callable[Args, Result]]: ...
29
+
30
+
31
+ def cached[**Args, Result](
32
+ function: Callable[Args, Result] | None = None,
33
+ *,
34
+ limit: int = 1,
35
+ expiration: float | None = None,
36
+ ) -> Callable[[Callable[Args, Result]], Callable[Args, Result]] | Callable[Args, Result]:
37
+ """\
38
+ Simple lru function result cache with optional expire time. \
39
+ Works for both sync and async functions. \
40
+ It is not allowed to be used on class methods. \
41
+ This wrapper is not thread safe.
42
+
43
+ Parameters
44
+ ----------
45
+ function: Callable[_Args, _Result]
46
+ function to wrap in cache, either sync or async
47
+ limit: int
48
+ limit of cache entries to keep, default is 1
49
+ expiration: float | None
50
+ entries expiration time in seconds, default is None (not expiring)
51
+
52
+ Returns
53
+ -------
54
+ Callable[[Callable[_Args, _Result]], Callable[_Args, _Result]] | Callable[_Args, _Result]
55
+ provided function wrapped in cache
56
+ """
57
+
58
+ def _wrap(function: Callable[Args, Result]) -> Callable[Args, Result]:
59
+ if iscoroutinefunction(function):
60
+ return cast(
61
+ Callable[Args, Result],
62
+ _AsyncCache(
63
+ function,
64
+ limit=limit,
65
+ expiration=expiration,
66
+ ),
67
+ )
68
+
69
+ else:
70
+ return cast(
71
+ Callable[Args, Result],
72
+ _SyncCache(
73
+ function,
74
+ limit=limit,
75
+ expiration=expiration,
76
+ ),
77
+ )
78
+
79
+ if function := function:
80
+ return _wrap(function)
81
+
82
+ else:
83
+ return _wrap
84
+
85
+
86
+ class _CacheEntry[Entry](NamedTuple):
87
+ value: Entry
88
+ expire: float | None
89
+
90
+
91
+ class _SyncCache[**Args, Result]:
92
+ def __init__(
93
+ self,
94
+ function: Callable[Args, Result],
95
+ /,
96
+ limit: int,
97
+ expiration: float | None,
98
+ ) -> None:
99
+ self._function: Callable[Args, Result] = function
100
+ self._cached: OrderedDict[Hashable, _CacheEntry[Result]] = OrderedDict()
101
+ self._limit: int = limit
102
+ if expiration := expiration:
103
+
104
+ def next_expire_time() -> float | None:
105
+ return monotonic() + expiration
106
+
107
+ else:
108
+
109
+ def next_expire_time() -> float | None:
110
+ return None
111
+
112
+ self._next_expire_time: Callable[[], float | None] = next_expire_time
113
+
114
+ # mimic function attributes if able
115
+ mimic_function(function, within=self)
116
+
117
+ def __get__(
118
+ self,
119
+ instance: object | None,
120
+ owner: type | None = None,
121
+ /,
122
+ ) -> Callable[Args, Result]:
123
+ if owner is None or instance is None:
124
+ return self
125
+
126
+ else:
127
+ return mimic_function(
128
+ self._function,
129
+ within=partial(
130
+ self.__method_call__,
131
+ instance,
132
+ ),
133
+ )
134
+
135
+ def __call__(
136
+ self,
137
+ *args: Args.args,
138
+ **kwargs: Args.kwargs,
139
+ ) -> Result:
140
+ key: Hashable = _make_key(
141
+ args=args,
142
+ kwds=kwargs,
143
+ typed=True,
144
+ )
145
+
146
+ match self._cached.get(key):
147
+ case None:
148
+ pass
149
+
150
+ case entry:
151
+ if (expire := entry[1]) and expire < monotonic():
152
+ # if still running let it complete if able
153
+ del self._cached[key] # continue the same way as if empty
154
+
155
+ else:
156
+ self._cached.move_to_end(key)
157
+ return entry[0]
158
+
159
+ result: Result = self._function(*args, **kwargs)
160
+ self._cached[key] = _CacheEntry(
161
+ value=result,
162
+ expire=self._next_expire_time(),
163
+ )
164
+
165
+ if len(self._cached) > self._limit:
166
+ # if still running let it complete if able
167
+ self._cached.popitem(last=False)
168
+
169
+ return result
170
+
171
+ def __method_call__(
172
+ self,
173
+ __method_self: object,
174
+ *args: Args.args,
175
+ **kwargs: Args.kwargs,
176
+ ) -> Result:
177
+ key: Hashable = _make_key(
178
+ args=(ref(__method_self), *args),
179
+ kwds=kwargs,
180
+ typed=True,
181
+ )
182
+
183
+ match self._cached.get(key):
184
+ case None:
185
+ pass
186
+
187
+ case entry:
188
+ if (expire := entry[1]) and expire < monotonic():
189
+ # if still running let it complete if able
190
+ del self._cached[key] # continue the same way as if empty
191
+
192
+ else:
193
+ self._cached.move_to_end(key)
194
+ return entry[0]
195
+
196
+ result: Result = self._function(__method_self, *args, **kwargs) # pyright: ignore[reportUnknownVariableType, reportCallIssue]
197
+ self._cached[key] = _CacheEntry(
198
+ value=result, # pyright: ignore[reportUnknownArgumentType]
199
+ expire=self._next_expire_time(),
200
+ )
201
+ if len(self._cached) > self._limit:
202
+ # if still running let it complete if able
203
+ self._cached.popitem(last=False)
204
+
205
+ return result # pyright: ignore[reportUnknownArgumentType, reportUnknownVariableType]
206
+
207
+
208
+ class _AsyncCache[**Args, Result]:
209
+ def __init__(
210
+ self,
211
+ function: Callable[Args, Coroutine[None, None, Result]],
212
+ /,
213
+ limit: int,
214
+ expiration: float | None,
215
+ ) -> None:
216
+ self._function: Callable[Args, Coroutine[None, None, Result]] = function
217
+ self._cached: OrderedDict[Hashable, _CacheEntry[Task[Result]]] = OrderedDict()
218
+ self._limit: int = limit
219
+ if expiration := expiration:
220
+
221
+ def next_expire_time() -> float | None:
222
+ return monotonic() + expiration
223
+
224
+ else:
225
+
226
+ def next_expire_time() -> float | None:
227
+ return None
228
+
229
+ self._next_expire_time: Callable[[], float | None] = next_expire_time
230
+
231
+ # mimic function attributes if able
232
+ mimic_function(function, within=self)
233
+
234
+ def __get__(
235
+ self,
236
+ instance: object | None,
237
+ owner: type | None = None,
238
+ /,
239
+ ) -> Callable[Args, Coroutine[None, None, Result]]:
240
+ if owner is None or instance is None:
241
+ return self
242
+
243
+ else:
244
+ return mimic_function(
245
+ self._function,
246
+ within=partial(
247
+ self.__method_call__,
248
+ instance,
249
+ ),
250
+ )
251
+
252
+ async def __call__(
253
+ self,
254
+ *args: Args.args,
255
+ **kwargs: Args.kwargs,
256
+ ) -> Result:
257
+ loop: AbstractEventLoop = get_running_loop()
258
+ key: Hashable = _make_key(
259
+ args=args,
260
+ kwds=kwargs,
261
+ typed=True,
262
+ )
263
+
264
+ match self._cached.get(key):
265
+ case None:
266
+ pass
267
+
268
+ case entry:
269
+ if (expire := entry[1]) and expire < monotonic():
270
+ # if still running let it complete if able
271
+ del self._cached[key] # continue the same way as if empty
272
+
273
+ else:
274
+ self._cached.move_to_end(key)
275
+ return await shield(entry[0])
276
+
277
+ task: Task[Result] = loop.create_task(self._function(*args, **kwargs)) # pyright: ignore[reportCallIssue]
278
+ self._cached[key] = _CacheEntry(
279
+ value=task,
280
+ expire=self._next_expire_time(),
281
+ )
282
+ if len(self._cached) > self._limit:
283
+ # if still running let it complete if able
284
+ self._cached.popitem(last=False)
285
+
286
+ return await shield(task)
287
+
288
+ async def __method_call__(
289
+ self,
290
+ __method_self: object,
291
+ *args: Args.args,
292
+ **kwargs: Args.kwargs,
293
+ ) -> Result:
294
+ loop: AbstractEventLoop = get_running_loop()
295
+ key: Hashable = _make_key(
296
+ args=(ref(__method_self), *args),
297
+ kwds=kwargs,
298
+ typed=True,
299
+ )
300
+
301
+ match self._cached.get(key):
302
+ case None:
303
+ pass
304
+
305
+ case entry:
306
+ if (expire := entry[1]) and expire < monotonic():
307
+ # if still running let it complete if able
308
+ del self._cached[key] # continue the same way as if empty
309
+
310
+ else:
311
+ self._cached.move_to_end(key)
312
+ return await shield(entry[0])
313
+
314
+ task: Task[Result] = loop.create_task(
315
+ self._function(__method_self, *args, **kwargs), # pyright: ignore[reportCallIssue, reportUnknownArgumentType]
316
+ )
317
+ self._cached[key] = _CacheEntry(
318
+ value=task,
319
+ expire=self._next_expire_time(),
320
+ )
321
+
322
+ if len(self._cached) > self._limit:
323
+ # if still running let it complete if able
324
+ self._cached.popitem(last=False)
325
+
326
+ return await shield(task)