haiway 0.10.14__py3-none-any.whl → 0.10.16__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 (43) hide show
  1. haiway/__init__.py +111 -0
  2. haiway/context/__init__.py +27 -0
  3. haiway/context/access.py +615 -0
  4. haiway/context/disposables.py +78 -0
  5. haiway/context/identifier.py +92 -0
  6. haiway/context/logging.py +176 -0
  7. haiway/context/metrics.py +165 -0
  8. haiway/context/state.py +113 -0
  9. haiway/context/tasks.py +64 -0
  10. haiway/context/types.py +12 -0
  11. haiway/helpers/__init__.py +21 -0
  12. haiway/helpers/asynchrony.py +225 -0
  13. haiway/helpers/caching.py +326 -0
  14. haiway/helpers/metrics.py +459 -0
  15. haiway/helpers/retries.py +223 -0
  16. haiway/helpers/throttling.py +133 -0
  17. haiway/helpers/timeouted.py +112 -0
  18. haiway/helpers/tracing.py +137 -0
  19. haiway/py.typed +0 -0
  20. haiway/state/__init__.py +12 -0
  21. haiway/state/attributes.py +747 -0
  22. haiway/state/path.py +524 -0
  23. haiway/state/requirement.py +229 -0
  24. haiway/state/structure.py +414 -0
  25. haiway/state/validation.py +468 -0
  26. haiway/types/__init__.py +14 -0
  27. haiway/types/default.py +108 -0
  28. haiway/types/frozen.py +5 -0
  29. haiway/types/missing.py +95 -0
  30. haiway/utils/__init__.py +28 -0
  31. haiway/utils/always.py +61 -0
  32. haiway/utils/collections.py +185 -0
  33. haiway/utils/env.py +230 -0
  34. haiway/utils/freezing.py +28 -0
  35. haiway/utils/logs.py +57 -0
  36. haiway/utils/mimic.py +77 -0
  37. haiway/utils/noop.py +24 -0
  38. haiway/utils/queue.py +82 -0
  39. {haiway-0.10.14.dist-info → haiway-0.10.16.dist-info}/METADATA +1 -1
  40. haiway-0.10.16.dist-info/RECORD +42 -0
  41. haiway-0.10.14.dist-info/RECORD +0 -4
  42. {haiway-0.10.14.dist-info → haiway-0.10.16.dist-info}/WHEEL +0 -0
  43. {haiway-0.10.14.dist-info → haiway-0.10.16.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,92 @@
1
+ from contextvars import ContextVar, Token
2
+ from types import TracebackType
3
+ from typing import Any, Self, final
4
+ from uuid import uuid4
5
+
6
+ __all__ = [
7
+ "ScopeIdentifier",
8
+ ]
9
+
10
+
11
+ @final
12
+ class ScopeIdentifier:
13
+ _context = ContextVar[Self]("ScopeIdentifier")
14
+
15
+ @classmethod
16
+ def current_trace_id(cls) -> str:
17
+ try:
18
+ return ScopeIdentifier._context.get().trace_id
19
+
20
+ except LookupError as exc:
21
+ raise RuntimeError("Attempting to access scope identifier outside of scope") from exc
22
+
23
+ @classmethod
24
+ def scope(
25
+ cls,
26
+ label: str,
27
+ /,
28
+ ) -> Self:
29
+ current: Self
30
+ try: # check for current scope
31
+ current = cls._context.get()
32
+
33
+ except LookupError:
34
+ # create root scope when missing
35
+ trace_id: str = uuid4().hex
36
+ return cls(
37
+ label=label,
38
+ scope_id=uuid4().hex,
39
+ parent_id=trace_id, # trace_id is parent_id for root
40
+ trace_id=trace_id,
41
+ )
42
+
43
+ # create nested scope otherwise
44
+ return cls(
45
+ label=label,
46
+ scope_id=uuid4().hex,
47
+ parent_id=current.scope_id,
48
+ trace_id=current.trace_id,
49
+ )
50
+
51
+ def __init__(
52
+ self,
53
+ trace_id: str,
54
+ parent_id: str,
55
+ scope_id: str,
56
+ label: str,
57
+ ) -> None:
58
+ self.trace_id: str = trace_id
59
+ self.parent_id: str = parent_id
60
+ self.scope_id: str = scope_id
61
+ self.label: str = label
62
+ self.unique_name: str = f"[{trace_id}] [{label}] [{scope_id}]"
63
+
64
+ @property
65
+ def is_root(self) -> bool:
66
+ return self.trace_id == self.parent_id
67
+
68
+ def __str__(self) -> str:
69
+ return self.unique_name
70
+
71
+ def __eq__(self, other: Any) -> bool:
72
+ if not isinstance(other, self.__class__):
73
+ return False
74
+
75
+ return self.scope_id == other.scope_id and self.trace_id == other.trace_id
76
+
77
+ def __hash__(self) -> int:
78
+ return hash(self.scope_id)
79
+
80
+ def __enter__(self) -> None:
81
+ assert not hasattr(self, "_token"), "Context reentrance is not allowed" # nosec: B101
82
+ self._token: Token[ScopeIdentifier] = ScopeIdentifier._context.set(self)
83
+
84
+ def __exit__(
85
+ self,
86
+ exc_type: type[BaseException] | None,
87
+ exc_val: BaseException | None,
88
+ exc_tb: TracebackType | None,
89
+ ) -> None:
90
+ assert hasattr(self, "_token"), "Unbalanced context enter/exit" # nosec: B101
91
+ ScopeIdentifier._context.reset(self._token)
92
+ del self._token
@@ -0,0 +1,176 @@
1
+ from contextvars import ContextVar, Token
2
+ from logging import DEBUG, ERROR, INFO, WARNING, Logger, getLogger
3
+ from time import monotonic
4
+ from types import TracebackType
5
+ from typing import Any, Self, final
6
+
7
+ from haiway.context.identifier import ScopeIdentifier
8
+
9
+ __all__ = [
10
+ "LoggerContext",
11
+ ]
12
+
13
+
14
+ @final
15
+ class LoggerContext:
16
+ _context = ContextVar[Self]("LoggerContext")
17
+
18
+ @classmethod
19
+ def scope(
20
+ cls,
21
+ scope: ScopeIdentifier,
22
+ /,
23
+ *,
24
+ logger: Logger | None,
25
+ ) -> Self:
26
+ current: Self
27
+ try: # check for current scope
28
+ current = cls._context.get()
29
+
30
+ except LookupError:
31
+ # create root scope when missing
32
+ return cls(
33
+ scope=scope,
34
+ logger=logger,
35
+ )
36
+
37
+ # create nested scope otherwise
38
+ return cls(
39
+ scope=scope,
40
+ logger=logger or current._logger,
41
+ )
42
+
43
+ @classmethod
44
+ def log_error(
45
+ cls,
46
+ message: str,
47
+ /,
48
+ *args: Any,
49
+ exception: BaseException | None = None,
50
+ ) -> None:
51
+ try:
52
+ cls._context.get().log(
53
+ ERROR,
54
+ message,
55
+ *args,
56
+ exception=exception,
57
+ )
58
+
59
+ except LookupError:
60
+ getLogger().log(
61
+ ERROR,
62
+ message,
63
+ *args,
64
+ exc_info=exception,
65
+ )
66
+
67
+ @classmethod
68
+ def log_warning(
69
+ cls,
70
+ message: str,
71
+ /,
72
+ *args: Any,
73
+ exception: Exception | None = None,
74
+ ) -> None:
75
+ try:
76
+ cls._context.get().log(
77
+ WARNING,
78
+ message,
79
+ *args,
80
+ exception=exception,
81
+ )
82
+
83
+ except LookupError:
84
+ getLogger().log(
85
+ WARNING,
86
+ message,
87
+ *args,
88
+ exc_info=exception,
89
+ )
90
+
91
+ @classmethod
92
+ def log_info(
93
+ cls,
94
+ message: str,
95
+ /,
96
+ *args: Any,
97
+ ) -> None:
98
+ try:
99
+ cls._context.get().log(
100
+ INFO,
101
+ message,
102
+ *args,
103
+ )
104
+
105
+ except LookupError:
106
+ getLogger().log(
107
+ INFO,
108
+ message,
109
+ *args,
110
+ )
111
+
112
+ @classmethod
113
+ def log_debug(
114
+ cls,
115
+ message: str,
116
+ /,
117
+ *args: Any,
118
+ exception: Exception | None = None,
119
+ ) -> None:
120
+ try:
121
+ cls._context.get().log(
122
+ DEBUG,
123
+ message,
124
+ *args,
125
+ exception=exception,
126
+ )
127
+
128
+ except LookupError:
129
+ getLogger().log(
130
+ DEBUG,
131
+ message,
132
+ *args,
133
+ exc_info=exception,
134
+ )
135
+
136
+ def __init__(
137
+ self,
138
+ scope: ScopeIdentifier,
139
+ logger: Logger | None,
140
+ ) -> None:
141
+ self._prefix: str = scope.unique_name
142
+ self._logger: Logger = logger or getLogger(name=scope.label)
143
+
144
+ def log(
145
+ self,
146
+ level: int,
147
+ message: str,
148
+ /,
149
+ *args: Any,
150
+ exception: BaseException | None = None,
151
+ ) -> None:
152
+ self._logger.log(
153
+ level,
154
+ f"{self._prefix} {message}",
155
+ *args,
156
+ exc_info=exception,
157
+ )
158
+
159
+ def __enter__(self) -> None:
160
+ assert not hasattr(self, "_token"), "Context reentrance is not allowed" # nosec: B101
161
+ assert not hasattr(self, "_entered"), "Context reentrance is not allowed" # nosec: B101
162
+ self._entered: float = monotonic()
163
+ self._token: Token[LoggerContext] = LoggerContext._context.set(self)
164
+ self.log(DEBUG, "Entering context...")
165
+
166
+ def __exit__(
167
+ self,
168
+ exc_type: type[BaseException] | None,
169
+ exc_val: BaseException | None,
170
+ exc_tb: TracebackType | None,
171
+ ) -> None:
172
+ assert hasattr(self, "_token"), "Unbalanced context enter/exit" # nosec: B101
173
+ LoggerContext._context.reset(self._token)
174
+ del self._token
175
+ self.log(DEBUG, f"...exiting context after {monotonic() - self._entered:.2f}s")
176
+ del self._entered
@@ -0,0 +1,165 @@
1
+ from contextvars import ContextVar, Token
2
+ from types import TracebackType
3
+ from typing import Protocol, Self, final, runtime_checkable
4
+
5
+ from haiway.context.identifier import ScopeIdentifier
6
+ from haiway.context.logging import LoggerContext
7
+ from haiway.state import State
8
+
9
+ __all__ = [
10
+ "MetricsContext",
11
+ "MetricsHandler",
12
+ "MetricsReading",
13
+ "MetricsRecording",
14
+ "MetricsScopeEntering",
15
+ "MetricsScopeExiting",
16
+ ]
17
+
18
+
19
+ @runtime_checkable
20
+ class MetricsRecording(Protocol):
21
+ def __call__(
22
+ self,
23
+ scope: ScopeIdentifier,
24
+ /,
25
+ metric: State,
26
+ ) -> None: ...
27
+
28
+
29
+ @runtime_checkable
30
+ class MetricsReading(Protocol):
31
+ async def __call__[Metric: State](
32
+ self,
33
+ scope: ScopeIdentifier,
34
+ /,
35
+ *,
36
+ metric: type[Metric],
37
+ merged: bool,
38
+ ) -> Metric | None: ...
39
+
40
+
41
+ @runtime_checkable
42
+ class MetricsScopeEntering(Protocol):
43
+ def __call__[Metric: State](
44
+ self,
45
+ scope: ScopeIdentifier,
46
+ /,
47
+ ) -> None: ...
48
+
49
+
50
+ @runtime_checkable
51
+ class MetricsScopeExiting(Protocol):
52
+ def __call__[Metric: State](
53
+ self,
54
+ scope: ScopeIdentifier,
55
+ /,
56
+ ) -> None: ...
57
+
58
+
59
+ class MetricsHandler(State):
60
+ record: MetricsRecording
61
+ read: MetricsReading
62
+ enter_scope: MetricsScopeEntering
63
+ exit_scope: MetricsScopeExiting
64
+
65
+
66
+ @final
67
+ class MetricsContext:
68
+ _context = ContextVar[Self]("MetricsContext")
69
+
70
+ @classmethod
71
+ def scope(
72
+ cls,
73
+ scope: ScopeIdentifier,
74
+ /,
75
+ *,
76
+ metrics: MetricsHandler | None,
77
+ ) -> Self:
78
+ current: Self
79
+ try: # check for current scope
80
+ current = cls._context.get()
81
+
82
+ except LookupError:
83
+ # create root scope when missing
84
+ return cls(
85
+ scope=scope,
86
+ metrics=metrics,
87
+ )
88
+
89
+ # create nested scope otherwise
90
+ return cls(
91
+ scope=scope,
92
+ metrics=metrics or current._metrics,
93
+ )
94
+
95
+ @classmethod
96
+ def record(
97
+ cls,
98
+ metric: State,
99
+ /,
100
+ ) -> None:
101
+ try: # catch exceptions - we don't wan't to blow up on metrics
102
+ metrics: Self = cls._context.get()
103
+
104
+ if metrics._metrics is not None:
105
+ metrics._metrics.record(
106
+ metrics._scope,
107
+ metric,
108
+ )
109
+
110
+ except Exception as exc:
111
+ LoggerContext.log_error(
112
+ "Failed to record metric: %s",
113
+ type(metric).__qualname__,
114
+ exception=exc,
115
+ )
116
+
117
+ @classmethod
118
+ async def read[Metric: State](
119
+ cls,
120
+ metric: type[Metric],
121
+ /,
122
+ merged: bool,
123
+ ) -> Metric | None:
124
+ try: # catch exceptions - we don't wan't to blow up on metrics
125
+ metrics: Self = cls._context.get()
126
+
127
+ if metrics._metrics is not None:
128
+ return await metrics._metrics.read(
129
+ metrics._scope,
130
+ metric=metric,
131
+ merged=merged,
132
+ )
133
+
134
+ except Exception as exc:
135
+ LoggerContext.log_error(
136
+ "Failed to read metric: %s",
137
+ metric.__qualname__,
138
+ exception=exc,
139
+ )
140
+
141
+ def __init__(
142
+ self,
143
+ scope: ScopeIdentifier,
144
+ metrics: MetricsHandler | None,
145
+ ) -> None:
146
+ self._scope: ScopeIdentifier = scope
147
+ self._metrics: MetricsHandler | None = metrics
148
+
149
+ def __enter__(self) -> None:
150
+ assert not hasattr(self, "_token"), "Context reentrance is not allowed" # nosec: B101
151
+ self._token: Token[MetricsContext] = MetricsContext._context.set(self)
152
+ if self._metrics is not None:
153
+ self._metrics.enter_scope(self._scope)
154
+
155
+ def __exit__(
156
+ self,
157
+ exc_type: type[BaseException] | None,
158
+ exc_val: BaseException | None,
159
+ exc_tb: TracebackType | None,
160
+ ) -> None:
161
+ assert hasattr(self, "_token"), "Unbalanced context enter/exit" # nosec: B101
162
+ MetricsContext._context.reset(self._token)
163
+ del self._token
164
+ if self._metrics is not None:
165
+ self._metrics.exit_scope(self._scope)
@@ -0,0 +1,113 @@
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 State
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[State],
21
+ ) -> None:
22
+ self._state: dict[type[State], State] = {type(element): element for element in state}
23
+ freeze(self)
24
+
25
+ def state[StateType: State](
26
+ self,
27
+ state: type[StateType],
28
+ /,
29
+ default: StateType | None = None,
30
+ ) -> StateType:
31
+ if state in self._state:
32
+ return cast(StateType, self._state[state])
33
+
34
+ elif default is not None:
35
+ return default
36
+
37
+ else:
38
+ try:
39
+ initialized: StateType = state()
40
+ self._state[state] = initialized
41
+ return initialized
42
+
43
+ except Exception as exc:
44
+ raise MissingState(
45
+ f"{state.__qualname__} is not defined in current scope"
46
+ " and failed to provide a default value"
47
+ ) from exc
48
+
49
+ def updated(
50
+ self,
51
+ state: Iterable[State],
52
+ ) -> Self:
53
+ if state:
54
+ return self.__class__(
55
+ [
56
+ *self._state.values(),
57
+ *state,
58
+ ]
59
+ )
60
+
61
+ else:
62
+ return self
63
+
64
+
65
+ @final
66
+ class StateContext:
67
+ _context = ContextVar[ScopeState]("StateContext")
68
+
69
+ @classmethod
70
+ def current[StateType: State](
71
+ cls,
72
+ state: type[StateType],
73
+ /,
74
+ default: StateType | None = None,
75
+ ) -> StateType:
76
+ try:
77
+ return cls._context.get().state(state, default=default)
78
+
79
+ except LookupError as exc:
80
+ raise MissingContext("StateContext requested but not defined!") from exc
81
+
82
+ @classmethod
83
+ def updated(
84
+ cls,
85
+ state: Iterable[State],
86
+ /,
87
+ ) -> Self:
88
+ try:
89
+ # update current scope context
90
+ return cls(state=cls._context.get().updated(state=state))
91
+
92
+ except LookupError: # create root scope when missing
93
+ return cls(state=ScopeState(state))
94
+
95
+ def __init__(
96
+ self,
97
+ state: ScopeState,
98
+ ) -> None:
99
+ self._state: ScopeState = state
100
+
101
+ def __enter__(self) -> None:
102
+ assert not hasattr(self, "_token"), "Context reentrance is not allowed" # nosec: B101
103
+ self._token: Token[ScopeState] = StateContext._context.set(self._state)
104
+
105
+ def __exit__(
106
+ self,
107
+ exc_type: type[BaseException] | None,
108
+ exc_val: BaseException | None,
109
+ exc_tb: TracebackType | None,
110
+ ) -> None:
111
+ assert hasattr(self, "_token"), "Unbalanced context enter/exit" # nosec: B101
112
+ StateContext._context.reset(self._token)
113
+ del self._token
@@ -0,0 +1,64 @@
1
+ from asyncio import Task, TaskGroup, get_event_loop
2
+ from collections.abc import Callable, Coroutine
3
+ from contextvars import ContextVar, 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
+
41
+ async def __aenter__(self) -> None:
42
+ assert not hasattr(self, "_token"), "Context reentrance is not allowed" # nosec: B101
43
+ await self._group.__aenter__()
44
+ self._token = TaskGroupContext._context.set(self._group)
45
+
46
+ async def __aexit__(
47
+ self,
48
+ exc_type: type[BaseException] | None,
49
+ exc_val: BaseException | None,
50
+ exc_tb: TracebackType | None,
51
+ ) -> None:
52
+ assert hasattr(self, "_token"), "Unbalanced context enter/exit" # nosec: B101
53
+ TaskGroupContext._context.reset(self._token)
54
+ del self._token
55
+
56
+ try:
57
+ await self._group.__aexit__(
58
+ et=exc_type,
59
+ exc=exc_val,
60
+ tb=exc_tb,
61
+ )
62
+
63
+ except BaseException:
64
+ pass # silence TaskGroup exceptions, if there was exception already we will get it
@@ -0,0 +1,12 @@
1
+ __all__ = [
2
+ "MissingContext",
3
+ "MissingState",
4
+ ]
5
+
6
+
7
+ class MissingContext(Exception):
8
+ pass
9
+
10
+
11
+ class MissingState(Exception):
12
+ pass
@@ -0,0 +1,21 @@
1
+ from haiway.helpers.asynchrony import asynchronous, wrap_async
2
+ from haiway.helpers.caching import cache
3
+ from haiway.helpers.metrics import MetricsHolder, MetricsLogger
4
+ from haiway.helpers.retries import retry
5
+ from haiway.helpers.throttling import throttle
6
+ from haiway.helpers.timeouted import timeout
7
+ from haiway.helpers.tracing import ArgumentsTrace, ResultTrace, traced
8
+
9
+ __all__ = [
10
+ "ArgumentsTrace",
11
+ "MetricsHolder",
12
+ "MetricsLogger",
13
+ "ResultTrace",
14
+ "asynchronous",
15
+ "cache",
16
+ "retry",
17
+ "throttle",
18
+ "timeout",
19
+ "traced",
20
+ "wrap_async",
21
+ ]