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.
haiway/__init__.py ADDED
@@ -0,0 +1,75 @@
1
+ from haiway.context import (
2
+ Dependencies,
3
+ Dependency,
4
+ MissingContext,
5
+ MissingDependency,
6
+ MissingState,
7
+ ScopeMetrics,
8
+ ctx,
9
+ )
10
+ from haiway.helpers import (
11
+ asynchronous,
12
+ auto_retry,
13
+ cached,
14
+ throttle,
15
+ with_timeout,
16
+ )
17
+ from haiway.state import Structure
18
+ from haiway.types import (
19
+ MISSING,
20
+ Missing,
21
+ frozenlist,
22
+ is_missing,
23
+ not_missing,
24
+ when_missing,
25
+ )
26
+ from haiway.utils import (
27
+ AsyncQueue,
28
+ always,
29
+ async_always,
30
+ async_noop,
31
+ freeze,
32
+ getenv_bool,
33
+ getenv_float,
34
+ getenv_int,
35
+ getenv_str,
36
+ load_env,
37
+ mimic_function,
38
+ noop,
39
+ setup_logging,
40
+ )
41
+
42
+ __all__ = [
43
+ "always",
44
+ "async_always",
45
+ "async_noop",
46
+ "asynchronous",
47
+ "AsyncQueue",
48
+ "auto_retry",
49
+ "cached",
50
+ "ctx",
51
+ "Dependencies",
52
+ "Dependency",
53
+ "freeze",
54
+ "frozenlist",
55
+ "getenv_bool",
56
+ "getenv_float",
57
+ "getenv_int",
58
+ "getenv_str",
59
+ "is_missing",
60
+ "load_env",
61
+ "mimic_function",
62
+ "Missing",
63
+ "MISSING",
64
+ "MissingContext",
65
+ "MissingDependency",
66
+ "MissingState",
67
+ "noop",
68
+ "not_missing",
69
+ "ScopeMetrics",
70
+ "setup_logging",
71
+ "Structure",
72
+ "throttle",
73
+ "when_missing",
74
+ "with_timeout",
75
+ ]
@@ -0,0 +1,14 @@
1
+ from haiway.context.access import ctx
2
+ from haiway.context.dependencies import Dependencies, Dependency
3
+ from haiway.context.metrics import ScopeMetrics
4
+ from haiway.context.types import MissingContext, MissingDependency, MissingState
5
+
6
+ __all__ = [
7
+ "ctx",
8
+ "Dependencies",
9
+ "Dependency",
10
+ "MissingContext",
11
+ "MissingDependency",
12
+ "MissingState",
13
+ "ScopeMetrics",
14
+ ]
@@ -0,0 +1,416 @@
1
+ from asyncio import (
2
+ Task,
3
+ current_task,
4
+ )
5
+ from collections.abc import (
6
+ Callable,
7
+ Coroutine,
8
+ )
9
+ from logging import Logger
10
+ from types import TracebackType
11
+ from typing import Any, final
12
+
13
+ from haiway.context.dependencies import Dependencies, Dependency
14
+ from haiway.context.metrics import MetricsContext, ScopeMetrics
15
+ from haiway.context.state import StateContext
16
+ from haiway.context.tasks import TaskGroupContext
17
+ from haiway.state import Structure
18
+ from haiway.utils import freeze
19
+
20
+ __all__ = [
21
+ "ctx",
22
+ ]
23
+
24
+
25
+ @final
26
+ class ScopeContext:
27
+ def __init__(
28
+ self,
29
+ task_group: TaskGroupContext,
30
+ state: StateContext,
31
+ metrics: MetricsContext,
32
+ completion: Callable[[ScopeMetrics], Coroutine[None, None, None]] | None,
33
+ ) -> None:
34
+ self._task_group: TaskGroupContext = task_group
35
+ self._state: StateContext = state
36
+ self._metrics: MetricsContext = metrics
37
+ self._completion: Callable[[ScopeMetrics], Coroutine[None, None, None]] | None = completion
38
+
39
+ freeze(self)
40
+
41
+ def __enter__(self) -> None:
42
+ assert self._completion is None, "Can't enter synchronous context with completion" # nosec: B101
43
+
44
+ self._state.__enter__()
45
+ self._metrics.__enter__()
46
+
47
+ def __exit__(
48
+ self,
49
+ exc_type: type[BaseException] | None,
50
+ exc_val: BaseException | None,
51
+ exc_tb: TracebackType | None,
52
+ ) -> None:
53
+ self._metrics.__exit__(
54
+ exc_type=exc_type,
55
+ exc_val=exc_val,
56
+ exc_tb=exc_tb,
57
+ )
58
+
59
+ self._state.__exit__(
60
+ exc_type=exc_type,
61
+ exc_val=exc_val,
62
+ exc_tb=exc_tb,
63
+ )
64
+
65
+ async def __aenter__(self) -> None:
66
+ self._state.__enter__()
67
+ self._metrics.__enter__()
68
+ await self._task_group.__aenter__()
69
+
70
+ async def __aexit__(
71
+ self,
72
+ exc_type: type[BaseException] | None,
73
+ exc_val: BaseException | None,
74
+ exc_tb: TracebackType | None,
75
+ ) -> None:
76
+ await self._task_group.__aexit__(
77
+ exc_type=exc_type,
78
+ exc_val=exc_val,
79
+ exc_tb=exc_tb,
80
+ )
81
+
82
+ self._metrics.__exit__(
83
+ exc_type=exc_type,
84
+ exc_val=exc_val,
85
+ exc_tb=exc_tb,
86
+ )
87
+
88
+ self._state.__exit__(
89
+ exc_type=exc_type,
90
+ exc_val=exc_val,
91
+ exc_tb=exc_tb,
92
+ )
93
+
94
+ if completion := self._completion:
95
+ await completion(self._metrics._metrics) # pyright: ignore[reportPrivateUsage]
96
+
97
+
98
+ @final
99
+ class ctx:
100
+ @staticmethod
101
+ def scope(
102
+ name: str,
103
+ /,
104
+ *state: Structure,
105
+ logger: Logger | None = None,
106
+ trace_id: str | None = None,
107
+ completion: Callable[[ScopeMetrics], Coroutine[None, None, None]] | None = None,
108
+ ) -> ScopeContext:
109
+ """
110
+ Access scope context with given parameters. When called within an existing context\
111
+ it becomes nested with current context as its predecessor.
112
+
113
+ Parameters
114
+ ----------
115
+ name: Value
116
+ name of the scope context
117
+
118
+ *state: Structure
119
+ state propagated within the scope context, will be merged with current if any\
120
+ by replacing current with provided on conflict
121
+
122
+ logger: Logger | None
123
+ logger used within the scope context, when not provided current logger will be used\
124
+ if any, otherwise the logger with the scope name will be requested.
125
+
126
+ trace_id: str | None = None
127
+ tracing identifier included in logs produced within the scope context, when not\
128
+ provided current identifier will be used if any, otherwise it random id will\
129
+ be generated
130
+
131
+ completion: Callable[[ScopeMetrics], Coroutine[None, None, None]] | None = None
132
+ completion callback called on exit from the scope granting access to finished\
133
+ scope metrics. Completion is called outside of the context when its metrics is\
134
+ already finished. Make sure to avoid any long operations within the completion.
135
+
136
+ Returns
137
+ -------
138
+ ScopeContext
139
+ context object intended to enter context manager with it
140
+ """
141
+
142
+ return ScopeContext(
143
+ task_group=TaskGroupContext(),
144
+ metrics=MetricsContext.scope(
145
+ name,
146
+ logger=logger,
147
+ trace_id=trace_id,
148
+ ),
149
+ state=StateContext.updated(state),
150
+ completion=completion,
151
+ )
152
+
153
+ @staticmethod
154
+ def updated(
155
+ *state: Structure,
156
+ ) -> StateContext:
157
+ """
158
+ Update scope context with given state. When called within an existing context\
159
+ it becomes nested with current context as its predecessor.
160
+
161
+ Parameters
162
+ ----------
163
+ *state: Structure
164
+ state propagated within the updated scope context, will be merged with current if any\
165
+ by replacing current with provided on conflict
166
+
167
+ Returns
168
+ -------
169
+ StateContext
170
+ state part of context object intended to enter context manager with it
171
+ """
172
+
173
+ return StateContext.updated(state)
174
+
175
+ @staticmethod
176
+ def spawn[Result, **Arguments](
177
+ function: Callable[Arguments, Coroutine[None, None, Result]],
178
+ /,
179
+ *args: Arguments.args,
180
+ **kwargs: Arguments.kwargs,
181
+ ) -> Task[Result]:
182
+ """
183
+ Spawn an async task within current scope context task group. When called outside of context\
184
+ it will spawn detached task instead.
185
+
186
+ Parameters
187
+ ----------
188
+ function: Callable[Arguments, Coroutine[None, None, Result]]
189
+ function to be called within the task group
190
+
191
+ *args: Arguments.args
192
+ positional arguments passed to function call
193
+
194
+ **kwargs: Arguments.kwargs
195
+ keyword arguments passed to function call
196
+
197
+ Returns
198
+ -------
199
+ Task[Result]
200
+ task for tracking function execution and result
201
+ """
202
+
203
+ return TaskGroupContext.run(function, *args, **kwargs)
204
+
205
+ @staticmethod
206
+ def cancel() -> None:
207
+ """
208
+ Cancel current asyncio task
209
+ """
210
+
211
+ if task := current_task():
212
+ task.cancel()
213
+
214
+ else:
215
+ raise RuntimeError("Attempting to cancel context out of asyncio task")
216
+
217
+ @staticmethod
218
+ async def dependency[DependencyType: Dependency](
219
+ dependency: type[DependencyType],
220
+ /,
221
+ ) -> DependencyType:
222
+ """
223
+ Access current dependency by its type.
224
+
225
+ Parameters
226
+ ----------
227
+ dependency: type[DependencyType]
228
+ type of requested dependency
229
+
230
+ Returns
231
+ -------
232
+ DependencyType
233
+ resolved dependency instance
234
+ """
235
+
236
+ return await Dependencies.dependency(dependency)
237
+
238
+ @staticmethod
239
+ def state[StateType: Structure](
240
+ state: type[StateType],
241
+ /,
242
+ default: StateType | None = None,
243
+ ) -> StateType:
244
+ """
245
+ Access current scope context state by its type. If there is no matching state defined\
246
+ default value will be created if able, an exception will raise otherwise.
247
+
248
+ Parameters
249
+ ----------
250
+ state: type[StateType]
251
+ type of requested state
252
+
253
+ Returns
254
+ -------
255
+ StateType
256
+ resolved state instance
257
+ """
258
+ return StateContext.current(
259
+ state,
260
+ default=default,
261
+ )
262
+
263
+ @staticmethod
264
+ def record[Metric: Structure](
265
+ metric: Metric,
266
+ /,
267
+ merge: Callable[[Metric, Metric], Metric] = lambda lhs, rhs: rhs,
268
+ ) -> None:
269
+ """
270
+ Record metric within current scope context.
271
+
272
+ Parameters
273
+ ----------
274
+ metric: MetricType
275
+ value of metric to be recorded
276
+
277
+ merge: Callable[[MetricType, MetricType], MetricType] = lambda lhs, rhs: rhs
278
+ merge method used on to resolve conflicts when a metric of the same type\
279
+ was already recorded. When not provided value will be override current if any.
280
+
281
+ Returns
282
+ -------
283
+ None
284
+ """
285
+
286
+ MetricsContext.record(
287
+ metric,
288
+ merge=merge,
289
+ )
290
+
291
+ @staticmethod
292
+ def log_error(
293
+ message: str,
294
+ /,
295
+ *args: Any,
296
+ exception: BaseException | None = None,
297
+ ) -> None:
298
+ """
299
+ Log using ERROR level within current scope context. When there is no current scope\
300
+ root logger will be used without additional details.
301
+
302
+ Parameters
303
+ ----------
304
+ message: str
305
+ message to be written to log
306
+
307
+ *args: Any
308
+ message format arguments
309
+
310
+ exception: BaseException | None = None
311
+ exception associated with log, when provided full stack trace will be recorded
312
+
313
+ Returns
314
+ -------
315
+ None
316
+ """
317
+
318
+ MetricsContext.log_error(
319
+ message,
320
+ *args,
321
+ exception=exception,
322
+ )
323
+
324
+ @staticmethod
325
+ def log_warning(
326
+ message: str,
327
+ /,
328
+ *args: Any,
329
+ exception: Exception | None = None,
330
+ ) -> None:
331
+ """
332
+ Log using WARNING level within current scope context. When there is no current scope\
333
+ root logger will be used without additional details.
334
+
335
+ Parameters
336
+ ----------
337
+ message: str
338
+ message to be written to log
339
+
340
+ *args: Any
341
+ message format arguments
342
+
343
+ exception: BaseException | None = None
344
+ exception associated with log, when provided full stack trace will be recorded
345
+
346
+ Returns
347
+ -------
348
+ None
349
+ """
350
+
351
+ MetricsContext.log_warning(
352
+ message,
353
+ *args,
354
+ exception=exception,
355
+ )
356
+
357
+ @staticmethod
358
+ def log_info(
359
+ message: str,
360
+ /,
361
+ *args: Any,
362
+ ) -> None:
363
+ """
364
+ Log using INFO level within current scope context. When there is no current scope\
365
+ root logger will be used without additional details.
366
+
367
+ Parameters
368
+ ----------
369
+ message: str
370
+ message to be written to log
371
+
372
+ *args: Any
373
+ message format arguments
374
+
375
+ Returns
376
+ -------
377
+ None
378
+ """
379
+
380
+ MetricsContext.log_info(
381
+ message,
382
+ *args,
383
+ )
384
+
385
+ @staticmethod
386
+ def log_debug(
387
+ message: str,
388
+ /,
389
+ *args: Any,
390
+ exception: Exception | None = None,
391
+ ) -> None:
392
+ """
393
+ Log using DEBUG level within current scope context. When there is no current scope\
394
+ root logger will be used without additional details.
395
+
396
+ Parameters
397
+ ----------
398
+ message: str
399
+ message to be written to log
400
+
401
+ *args: Any
402
+ message format arguments
403
+
404
+ exception: BaseException | None = None
405
+ exception associated with log, when provided full stack trace will be recorded
406
+
407
+ Returns
408
+ -------
409
+ None
410
+ """
411
+
412
+ MetricsContext.log_debug(
413
+ message,
414
+ *args,
415
+ exception=exception,
416
+ )
@@ -0,0 +1,61 @@
1
+ from abc import ABC, abstractmethod
2
+ from asyncio import Lock, gather, shield
3
+ from typing import ClassVar, Self, cast, final
4
+
5
+ __all__ = [
6
+ "Dependencies",
7
+ "Dependency",
8
+ ]
9
+
10
+
11
+ class Dependency(ABC):
12
+ @classmethod
13
+ @abstractmethod
14
+ async def prepare(cls) -> Self: ...
15
+
16
+ async def dispose(self) -> None: # noqa: B027
17
+ pass
18
+
19
+
20
+ @final
21
+ class Dependencies:
22
+ _lock: ClassVar[Lock] = Lock()
23
+ _dependencies: ClassVar[dict[type[Dependency], Dependency]] = {}
24
+
25
+ def __init__(self) -> None:
26
+ raise NotImplementedError("Can't instantiate Dependencies")
27
+
28
+ @classmethod
29
+ async def dependency[Requested: Dependency](
30
+ cls,
31
+ dependency: type[Requested],
32
+ /,
33
+ ) -> Requested:
34
+ async with cls._lock:
35
+ if dependency not in cls._dependencies:
36
+ cls._dependencies[dependency] = await dependency.prepare()
37
+
38
+ return cast(Requested, cls._dependencies[dependency])
39
+
40
+ @classmethod
41
+ async def register(
42
+ cls,
43
+ dependency: Dependency,
44
+ /,
45
+ ) -> None:
46
+ async with cls._lock:
47
+ if current := cls._dependencies.get(dependency.__class__):
48
+ await current.dispose()
49
+
50
+ cls._dependencies[dependency.__class__] = dependency
51
+
52
+ @classmethod
53
+ async def dispose(cls) -> None:
54
+ async with cls._lock:
55
+ await shield(
56
+ gather(
57
+ *[dependency.dispose() for dependency in cls._dependencies.values()],
58
+ return_exceptions=False,
59
+ )
60
+ )
61
+ cls._dependencies.clear()