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 +75 -0
- haiway/context/__init__.py +14 -0
- haiway/context/access.py +416 -0
- haiway/context/dependencies.py +61 -0
- haiway/context/metrics.py +329 -0
- haiway/context/state.py +115 -0
- haiway/context/tasks.py +65 -0
- haiway/context/types.py +17 -0
- haiway/helpers/__init__.py +13 -0
- haiway/helpers/asynchronous.py +226 -0
- haiway/helpers/cache.py +326 -0
- haiway/helpers/retry.py +210 -0
- haiway/helpers/throttling.py +133 -0
- haiway/helpers/timeout.py +112 -0
- haiway/py.typed +0 -0
- haiway/state/__init__.py +8 -0
- haiway/state/attributes.py +360 -0
- haiway/state/structure.py +254 -0
- haiway/state/validation.py +125 -0
- haiway/types/__init__.py +11 -0
- haiway/types/frozen.py +5 -0
- haiway/types/missing.py +91 -0
- haiway/utils/__init__.py +23 -0
- haiway/utils/always.py +61 -0
- haiway/utils/env.py +164 -0
- haiway/utils/immutable.py +28 -0
- haiway/utils/logs.py +57 -0
- haiway/utils/mimic.py +77 -0
- haiway/utils/noop.py +24 -0
- haiway/utils/queue.py +89 -0
- haiway-0.1.0.dist-info/LICENSE +21 -0
- haiway-0.1.0.dist-info/METADATA +86 -0
- haiway-0.1.0.dist-info/RECORD +35 -0
- haiway-0.1.0.dist-info/WHEEL +5 -0
- haiway-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,329 @@
|
|
1
|
+
from asyncio import Future, gather, get_event_loop
|
2
|
+
from collections.abc import Callable
|
3
|
+
from contextvars import ContextVar, Token
|
4
|
+
from copy import copy
|
5
|
+
from itertools import chain
|
6
|
+
from logging import DEBUG, ERROR, INFO, WARNING, Logger, getLogger
|
7
|
+
from time import monotonic
|
8
|
+
from types import TracebackType
|
9
|
+
from typing import Any, Self, cast, final, overload
|
10
|
+
from uuid import uuid4
|
11
|
+
|
12
|
+
from haiway.state import Structure
|
13
|
+
from haiway.utils import freeze
|
14
|
+
|
15
|
+
__all__ = [
|
16
|
+
"ScopeMetrics",
|
17
|
+
"MetricsContext",
|
18
|
+
]
|
19
|
+
|
20
|
+
|
21
|
+
@final
|
22
|
+
class ScopeMetrics:
|
23
|
+
def __init__(
|
24
|
+
self,
|
25
|
+
*,
|
26
|
+
trace_id: str | None,
|
27
|
+
scope: str,
|
28
|
+
logger: Logger | None,
|
29
|
+
) -> None:
|
30
|
+
self.trace_id: str = trace_id or uuid4().hex
|
31
|
+
self._label: str = f"{self.trace_id}|{scope}" if scope else self.trace_id
|
32
|
+
self._logger: Logger = logger or getLogger(name=scope)
|
33
|
+
self._metrics: dict[type[Structure], Structure] = {}
|
34
|
+
self._nested: list[ScopeMetrics] = []
|
35
|
+
self._timestamp: float = monotonic()
|
36
|
+
self._completed: Future[float] = get_event_loop().create_future()
|
37
|
+
|
38
|
+
freeze(self)
|
39
|
+
|
40
|
+
def __del__(self) -> None:
|
41
|
+
self._complete() # ensure completion on deinit
|
42
|
+
|
43
|
+
def __str__(self) -> str:
|
44
|
+
return self._label
|
45
|
+
|
46
|
+
def metrics(
|
47
|
+
self,
|
48
|
+
*,
|
49
|
+
merge: Callable[[Structure, Structure], Structure] = lambda lhs, rhs: lhs,
|
50
|
+
) -> list[Structure]:
|
51
|
+
metrics: dict[type[Structure], Structure] = copy(self._metrics)
|
52
|
+
for metric in chain.from_iterable(nested.metrics(merge=merge) for nested in self._nested):
|
53
|
+
metric_type: type[Structure] = type(metric)
|
54
|
+
if current := metrics.get(metric_type):
|
55
|
+
metrics[metric_type] = merge(current, metric)
|
56
|
+
|
57
|
+
else:
|
58
|
+
metrics[metric_type] = metric
|
59
|
+
|
60
|
+
return list(metrics.values())
|
61
|
+
|
62
|
+
@overload
|
63
|
+
def read[Metric: Structure](
|
64
|
+
self,
|
65
|
+
metric: type[Metric],
|
66
|
+
/,
|
67
|
+
) -> Metric | None: ...
|
68
|
+
|
69
|
+
@overload
|
70
|
+
def read[Metric: Structure](
|
71
|
+
self,
|
72
|
+
metric: type[Metric],
|
73
|
+
/,
|
74
|
+
default: Metric,
|
75
|
+
) -> Metric: ...
|
76
|
+
|
77
|
+
def read[Metric: Structure](
|
78
|
+
self,
|
79
|
+
metric: type[Metric],
|
80
|
+
/,
|
81
|
+
default: Metric | None = None,
|
82
|
+
) -> Metric | None:
|
83
|
+
return cast(Metric | None, self._metrics.get(metric, default))
|
84
|
+
|
85
|
+
def record[Metric: Structure](
|
86
|
+
self,
|
87
|
+
metric: Metric,
|
88
|
+
/,
|
89
|
+
*,
|
90
|
+
merge: Callable[[Metric, Metric], Metric] = lambda lhs, rhs: rhs,
|
91
|
+
) -> None:
|
92
|
+
assert not self._completed.done(), "Can't record using completed metrics scope" # nosec: B101
|
93
|
+
metric_type: type[Metric] = type(metric)
|
94
|
+
if current := self._metrics.get(metric_type):
|
95
|
+
self._metrics[metric_type] = merge(cast(Metric, current), metric)
|
96
|
+
|
97
|
+
else:
|
98
|
+
self._metrics[metric_type] = metric
|
99
|
+
|
100
|
+
@property
|
101
|
+
def completed(self) -> bool:
|
102
|
+
return self._completed.done() and all(nested.completed for nested in self._nested)
|
103
|
+
|
104
|
+
@property
|
105
|
+
def time(self) -> float:
|
106
|
+
if self._completed.done():
|
107
|
+
return self._completed.result()
|
108
|
+
|
109
|
+
else:
|
110
|
+
return monotonic() - self._timestamp
|
111
|
+
|
112
|
+
async def wait(self) -> None:
|
113
|
+
await gather(
|
114
|
+
self._completed,
|
115
|
+
*[nested.wait() for nested in self._nested],
|
116
|
+
return_exceptions=False,
|
117
|
+
)
|
118
|
+
|
119
|
+
def _complete(self) -> None:
|
120
|
+
if self._completed.done():
|
121
|
+
return # already completed
|
122
|
+
|
123
|
+
self._completed.set_result(monotonic() - self._timestamp)
|
124
|
+
|
125
|
+
def scope(
|
126
|
+
self,
|
127
|
+
name: str,
|
128
|
+
/,
|
129
|
+
) -> Self:
|
130
|
+
nested: Self = self.__class__(
|
131
|
+
scope=name,
|
132
|
+
logger=self._logger,
|
133
|
+
trace_id=self.trace_id,
|
134
|
+
)
|
135
|
+
self._nested.append(nested)
|
136
|
+
return nested
|
137
|
+
|
138
|
+
def log(
|
139
|
+
self,
|
140
|
+
level: int,
|
141
|
+
message: str,
|
142
|
+
/,
|
143
|
+
*args: Any,
|
144
|
+
exception: BaseException | None = None,
|
145
|
+
) -> None:
|
146
|
+
self._logger.log(
|
147
|
+
level,
|
148
|
+
f"[{self}] {message}",
|
149
|
+
*args,
|
150
|
+
exc_info=exception,
|
151
|
+
)
|
152
|
+
|
153
|
+
|
154
|
+
@final
|
155
|
+
class MetricsContext:
|
156
|
+
_context = ContextVar[ScopeMetrics]("MetricsContext")
|
157
|
+
|
158
|
+
@classmethod
|
159
|
+
def scope(
|
160
|
+
cls,
|
161
|
+
name: str,
|
162
|
+
/,
|
163
|
+
*,
|
164
|
+
trace_id: str | None = None,
|
165
|
+
logger: Logger | None = None,
|
166
|
+
) -> Self:
|
167
|
+
try:
|
168
|
+
context: ScopeMetrics = cls._context.get()
|
169
|
+
if trace_id is None or context.trace_id == trace_id:
|
170
|
+
return cls(context.scope(name))
|
171
|
+
|
172
|
+
else:
|
173
|
+
return cls(
|
174
|
+
ScopeMetrics(
|
175
|
+
trace_id=trace_id,
|
176
|
+
scope=name,
|
177
|
+
logger=logger or context._logger, # pyright: ignore[reportPrivateUsage]
|
178
|
+
)
|
179
|
+
)
|
180
|
+
except LookupError: # create metrics scope when missing yet
|
181
|
+
return cls(
|
182
|
+
ScopeMetrics(
|
183
|
+
trace_id=trace_id,
|
184
|
+
scope=name,
|
185
|
+
logger=logger,
|
186
|
+
)
|
187
|
+
)
|
188
|
+
|
189
|
+
@classmethod
|
190
|
+
def record[Metric: Structure](
|
191
|
+
cls,
|
192
|
+
metric: Metric,
|
193
|
+
/,
|
194
|
+
*,
|
195
|
+
merge: Callable[[Metric, Metric], Metric] = lambda lhs, rhs: rhs,
|
196
|
+
) -> None:
|
197
|
+
try: # catch exceptions - we don't wan't to blow up on metrics
|
198
|
+
cls._context.get().record(metric, merge=merge)
|
199
|
+
|
200
|
+
except Exception as exc:
|
201
|
+
cls.log_error(
|
202
|
+
"Failed to record metric: %s",
|
203
|
+
type(metric).__qualname__,
|
204
|
+
exception=exc,
|
205
|
+
)
|
206
|
+
|
207
|
+
# - LOGS -
|
208
|
+
|
209
|
+
@classmethod
|
210
|
+
def log_error(
|
211
|
+
cls,
|
212
|
+
message: str,
|
213
|
+
/,
|
214
|
+
*args: Any,
|
215
|
+
exception: BaseException | None = None,
|
216
|
+
) -> None:
|
217
|
+
try:
|
218
|
+
cls._context.get().log(
|
219
|
+
ERROR,
|
220
|
+
message,
|
221
|
+
*args,
|
222
|
+
exception=exception,
|
223
|
+
)
|
224
|
+
|
225
|
+
except LookupError:
|
226
|
+
getLogger().log(
|
227
|
+
ERROR,
|
228
|
+
message,
|
229
|
+
*args,
|
230
|
+
exc_info=exception,
|
231
|
+
)
|
232
|
+
|
233
|
+
@classmethod
|
234
|
+
def log_warning(
|
235
|
+
cls,
|
236
|
+
message: str,
|
237
|
+
/,
|
238
|
+
*args: Any,
|
239
|
+
exception: Exception | None = None,
|
240
|
+
) -> None:
|
241
|
+
try:
|
242
|
+
cls._context.get().log(
|
243
|
+
WARNING,
|
244
|
+
message,
|
245
|
+
*args,
|
246
|
+
exception=exception,
|
247
|
+
)
|
248
|
+
|
249
|
+
except LookupError:
|
250
|
+
getLogger().log(
|
251
|
+
WARNING,
|
252
|
+
message,
|
253
|
+
*args,
|
254
|
+
exc_info=exception,
|
255
|
+
)
|
256
|
+
|
257
|
+
@classmethod
|
258
|
+
def log_info(
|
259
|
+
cls,
|
260
|
+
message: str,
|
261
|
+
/,
|
262
|
+
*args: Any,
|
263
|
+
) -> None:
|
264
|
+
try:
|
265
|
+
cls._context.get().log(
|
266
|
+
INFO,
|
267
|
+
message,
|
268
|
+
*args,
|
269
|
+
)
|
270
|
+
|
271
|
+
except LookupError:
|
272
|
+
getLogger().log(
|
273
|
+
INFO,
|
274
|
+
message,
|
275
|
+
*args,
|
276
|
+
)
|
277
|
+
|
278
|
+
@classmethod
|
279
|
+
def log_debug(
|
280
|
+
cls,
|
281
|
+
message: str,
|
282
|
+
/,
|
283
|
+
*args: Any,
|
284
|
+
exception: Exception | None = None,
|
285
|
+
) -> None:
|
286
|
+
try:
|
287
|
+
cls._context.get().log(
|
288
|
+
DEBUG,
|
289
|
+
message,
|
290
|
+
*args,
|
291
|
+
exception=exception,
|
292
|
+
)
|
293
|
+
|
294
|
+
except LookupError:
|
295
|
+
getLogger().log(
|
296
|
+
DEBUG,
|
297
|
+
message,
|
298
|
+
*args,
|
299
|
+
exc_info=exception,
|
300
|
+
)
|
301
|
+
|
302
|
+
def __init__(
|
303
|
+
self,
|
304
|
+
metrics: ScopeMetrics,
|
305
|
+
) -> None:
|
306
|
+
self._metrics: ScopeMetrics = metrics
|
307
|
+
self._token: Token[ScopeMetrics] | None = None
|
308
|
+
self._started: float | None = None
|
309
|
+
self._finished: float | None = None
|
310
|
+
|
311
|
+
def __enter__(self) -> None:
|
312
|
+
assert ( # nosec: B101
|
313
|
+
self._token is None and self._started is None
|
314
|
+
), "MetricsContext reentrance is not allowed"
|
315
|
+
self._token = MetricsContext._context.set(self._metrics)
|
316
|
+
self._started = monotonic()
|
317
|
+
|
318
|
+
def __exit__(
|
319
|
+
self,
|
320
|
+
exc_type: type[BaseException] | None,
|
321
|
+
exc_val: BaseException | None,
|
322
|
+
exc_tb: TracebackType | None,
|
323
|
+
) -> None:
|
324
|
+
assert ( # nosec: B101
|
325
|
+
self._token is not None and self._started is not None and self._finished is None
|
326
|
+
), "Unbalanced MetricsContext context enter/exit"
|
327
|
+
self._finished = monotonic()
|
328
|
+
MetricsContext._context.reset(self._token)
|
329
|
+
self._token = None
|
haiway/context/state.py
ADDED
@@ -0,0 +1,115 @@
|
|
1
|
+
from collections.abc import Iterable
|
2
|
+
from contextvars import ContextVar, Token
|
3
|
+
from types import TracebackType
|
4
|
+
from typing import Self, cast, final
|
5
|
+
|
6
|
+
from haiway.context.types import MissingContext, MissingState
|
7
|
+
from haiway.state import Structure
|
8
|
+
from haiway.utils import freeze
|
9
|
+
|
10
|
+
__all__ = [
|
11
|
+
"ScopeState",
|
12
|
+
"StateContext",
|
13
|
+
]
|
14
|
+
|
15
|
+
|
16
|
+
@final
|
17
|
+
class ScopeState:
|
18
|
+
def __init__(
|
19
|
+
self,
|
20
|
+
state: Iterable[Structure],
|
21
|
+
) -> None:
|
22
|
+
self._state: dict[type[Structure], Structure] = {
|
23
|
+
type(element): element for element in state
|
24
|
+
}
|
25
|
+
freeze(self)
|
26
|
+
|
27
|
+
def state[State: Structure](
|
28
|
+
self,
|
29
|
+
state: type[State],
|
30
|
+
/,
|
31
|
+
default: State | None = None,
|
32
|
+
) -> State:
|
33
|
+
if state in self._state:
|
34
|
+
return cast(State, self._state[state])
|
35
|
+
|
36
|
+
elif default is not None:
|
37
|
+
return default
|
38
|
+
|
39
|
+
else:
|
40
|
+
try:
|
41
|
+
initialized: State = state()
|
42
|
+
self._state[state] = initialized
|
43
|
+
return initialized
|
44
|
+
|
45
|
+
except Exception as exc:
|
46
|
+
raise MissingState(
|
47
|
+
f"{state.__qualname__} is not defined in current scope"
|
48
|
+
" and failed to provide a default value"
|
49
|
+
) from exc
|
50
|
+
|
51
|
+
def updated(
|
52
|
+
self,
|
53
|
+
state: Iterable[Structure],
|
54
|
+
) -> Self:
|
55
|
+
if state:
|
56
|
+
return self.__class__(
|
57
|
+
[
|
58
|
+
*self._state.values(),
|
59
|
+
*state,
|
60
|
+
]
|
61
|
+
)
|
62
|
+
|
63
|
+
else:
|
64
|
+
return self
|
65
|
+
|
66
|
+
|
67
|
+
@final
|
68
|
+
class StateContext:
|
69
|
+
_context = ContextVar[ScopeState]("StateContext")
|
70
|
+
|
71
|
+
@classmethod
|
72
|
+
def current[State: Structure](
|
73
|
+
cls,
|
74
|
+
state: type[State],
|
75
|
+
/,
|
76
|
+
default: State | None = None,
|
77
|
+
) -> State:
|
78
|
+
try:
|
79
|
+
return cls._context.get().state(state, default=default)
|
80
|
+
|
81
|
+
except LookupError as exc:
|
82
|
+
raise MissingContext("StateContext requested but not defined!") from exc
|
83
|
+
|
84
|
+
@classmethod
|
85
|
+
def updated(
|
86
|
+
cls,
|
87
|
+
state: Iterable[Structure],
|
88
|
+
/,
|
89
|
+
) -> Self:
|
90
|
+
try:
|
91
|
+
return cls(state=cls._context.get().updated(state=state))
|
92
|
+
|
93
|
+
except LookupError: # create new context as a fallback
|
94
|
+
return cls(state=ScopeState(state))
|
95
|
+
|
96
|
+
def __init__(
|
97
|
+
self,
|
98
|
+
state: ScopeState,
|
99
|
+
) -> None:
|
100
|
+
self._state: ScopeState = state
|
101
|
+
self._token: Token[ScopeState] | None = None
|
102
|
+
|
103
|
+
def __enter__(self) -> None:
|
104
|
+
assert self._token is None, "StateContext reentrance is not allowed" # nosec: B101
|
105
|
+
self._token = StateContext._context.set(self._state)
|
106
|
+
|
107
|
+
def __exit__(
|
108
|
+
self,
|
109
|
+
exc_type: type[BaseException] | None,
|
110
|
+
exc_val: BaseException | None,
|
111
|
+
exc_tb: TracebackType | None,
|
112
|
+
) -> None:
|
113
|
+
assert self._token is not None, "Unbalanced StateContext context exit" # nosec: B101
|
114
|
+
StateContext._context.reset(self._token)
|
115
|
+
self._token = None
|
haiway/context/tasks.py
ADDED
@@ -0,0 +1,65 @@
|
|
1
|
+
from asyncio import Task, TaskGroup, get_event_loop
|
2
|
+
from collections.abc import Callable, Coroutine
|
3
|
+
from contextvars import ContextVar, Token, copy_context
|
4
|
+
from types import TracebackType
|
5
|
+
from typing import final
|
6
|
+
|
7
|
+
__all__ = [
|
8
|
+
"TaskGroupContext",
|
9
|
+
]
|
10
|
+
|
11
|
+
|
12
|
+
@final
|
13
|
+
class TaskGroupContext:
|
14
|
+
_context = ContextVar[TaskGroup]("TaskGroupContext")
|
15
|
+
|
16
|
+
@classmethod
|
17
|
+
def run[Result, **Arguments](
|
18
|
+
cls,
|
19
|
+
function: Callable[Arguments, Coroutine[None, None, Result]],
|
20
|
+
/,
|
21
|
+
*args: Arguments.args,
|
22
|
+
**kwargs: Arguments.kwargs,
|
23
|
+
) -> Task[Result]:
|
24
|
+
try:
|
25
|
+
return cls._context.get().create_task(
|
26
|
+
function(*args, **kwargs),
|
27
|
+
context=copy_context(),
|
28
|
+
)
|
29
|
+
|
30
|
+
except LookupError: # spawn task out of group as a fallback
|
31
|
+
return get_event_loop().create_task(
|
32
|
+
function(*args, **kwargs),
|
33
|
+
context=copy_context(),
|
34
|
+
)
|
35
|
+
|
36
|
+
def __init__(
|
37
|
+
self,
|
38
|
+
) -> None:
|
39
|
+
self._group: TaskGroup = TaskGroup()
|
40
|
+
self._token: Token[TaskGroup] | None = None
|
41
|
+
|
42
|
+
async def __aenter__(self) -> None:
|
43
|
+
assert self._token is None, "TaskGroupContext reentrance is not allowed" # nosec: B101
|
44
|
+
await self._group.__aenter__()
|
45
|
+
self._token = TaskGroupContext._context.set(self._group)
|
46
|
+
|
47
|
+
async def __aexit__(
|
48
|
+
self,
|
49
|
+
exc_type: type[BaseException] | None,
|
50
|
+
exc_val: BaseException | None,
|
51
|
+
exc_tb: TracebackType | None,
|
52
|
+
) -> None:
|
53
|
+
assert self._token is not None, "Unbalanced TaskGroupContext context exit" # nosec: B101
|
54
|
+
TaskGroupContext._context.reset(self._token)
|
55
|
+
self._token = None
|
56
|
+
|
57
|
+
try:
|
58
|
+
await self._group.__aexit__(
|
59
|
+
et=exc_type,
|
60
|
+
exc=exc_val,
|
61
|
+
tb=exc_tb,
|
62
|
+
)
|
63
|
+
|
64
|
+
except BaseException:
|
65
|
+
pass # silence TaskGroup exceptions, if there was exception already we will get it
|
haiway/context/types.py
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
from haiway.helpers.asynchronous import asynchronous
|
2
|
+
from haiway.helpers.cache import cached
|
3
|
+
from haiway.helpers.retry import auto_retry
|
4
|
+
from haiway.helpers.throttling import throttle
|
5
|
+
from haiway.helpers.timeout import with_timeout
|
6
|
+
|
7
|
+
__all__ = [
|
8
|
+
"asynchronous",
|
9
|
+
"auto_retry",
|
10
|
+
"cached",
|
11
|
+
"throttle",
|
12
|
+
"with_timeout",
|
13
|
+
]
|