haiway 0.24.3__py3-none-any.whl → 0.25.1__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,337 @@
1
+ from collections.abc import Collection, Iterable, Mapping
2
+ from contextvars import ContextVar, Token
3
+ from types import TracebackType
4
+ from typing import ClassVar, Protocol, Self, cast
5
+
6
+ from haiway.context.disposables import Disposable, Disposables
7
+ from haiway.state import Immutable, State
8
+ from haiway.types.default import Default
9
+
10
+ __all__ = (
11
+ "ContextPresets",
12
+ "ContextPresetsRegistry",
13
+ "ContextPresetsRegistryContext",
14
+ )
15
+
16
+
17
+ class ContextPresetsStatePreparing(Protocol):
18
+ async def __call__(self) -> Iterable[State] | State: ...
19
+
20
+
21
+ class ContextPresetsDisposablesPreparing(Protocol):
22
+ async def __call__(self) -> Iterable[Disposable] | Disposable: ...
23
+
24
+
25
+ class ContextPresets(Immutable):
26
+ """
27
+ A configuration preset for context scopes.
28
+
29
+ ContextPresets allow you to define reusable combinations of state and disposables
30
+ that can be applied to scopes by name. This provides a convenient way to manage
31
+ complex application configurations and resource setups.
32
+
33
+ State Priority
34
+ --------------
35
+ When used with ctx.scope(), preset state has lower priority than explicit state:
36
+ 1. Explicit state (passed to ctx.scope()) - **highest priority**
37
+ 2. Explicit disposables (passed to ctx.scope()) - medium priority
38
+ 3. Preset state (from presets) - low priority
39
+ 4. Contextual state (from parent contexts) - **lowest priority**
40
+
41
+ Examples
42
+ --------
43
+ Basic preset with static state:
44
+
45
+ >>> from haiway import State
46
+ >>> from haiway.context import ContextPresets
47
+ >>>
48
+ >>> class DatabaseConfig(State):
49
+ ... connection_string: str
50
+ ... pool_size: int = 10
51
+ >>>
52
+ >>> db_preset = ContextPresets(
53
+ ... name="database",
54
+ ... _state=[DatabaseConfig(connection_string="postgresql://localhost/app")]
55
+ ... )
56
+
57
+ Preset with dynamic state factory:
58
+
59
+ >>> async def load_config() -> DatabaseConfig:
60
+ ... # Load configuration from environment or config file
61
+ ... return DatabaseConfig(connection_string=os.getenv("DB_URL"))
62
+ >>>
63
+ >>> dynamic_preset = ContextPresets(
64
+ ... name="dynamic_db",
65
+ ... _state=[load_config]
66
+ ... )
67
+
68
+ Preset with disposables:
69
+
70
+ >>> from contextlib import asynccontextmanager
71
+ >>>
72
+ >>> @asynccontextmanager
73
+ >>> async def database_connection():
74
+ ... conn = await create_connection()
75
+ ... try:
76
+ ... yield ConnectionState(connection=conn)
77
+ ... finally:
78
+ ... await conn.close()
79
+ >>>
80
+ >>> async def connection_factory():
81
+ ... return database_connection()
82
+ >>>
83
+ >>> db_preset = ContextPresets(
84
+ ... name="database",
85
+ ... _state=[DatabaseConfig(connection_string="...")],
86
+ ... _disposables=[connection_factory]
87
+ ... )
88
+
89
+ Using presets:
90
+
91
+ >>> from haiway import ctx
92
+ >>>
93
+ >>> with ctx.presets(db_preset):
94
+ ... async with ctx.scope("database"):
95
+ ... config = ctx.state(DatabaseConfig)
96
+ ... # Use the preset configuration
97
+ """
98
+
99
+ name: str
100
+ _state: Collection[ContextPresetsStatePreparing | State] = Default(())
101
+ _disposables: Collection[ContextPresetsDisposablesPreparing] = Default(())
102
+
103
+ def extended(
104
+ self,
105
+ other: Self,
106
+ ) -> Self:
107
+ """
108
+ Create a new preset by extending this preset with another.
109
+
110
+ Combines the state and disposables from both presets, keeping the name
111
+ of the current preset. The other preset's state and disposables are
112
+ appended to this preset's collections.
113
+
114
+ Parameters
115
+ ----------
116
+ other : Self
117
+ Another ContextPresets instance to merge with this one.
118
+
119
+ Returns
120
+ -------
121
+ Self
122
+ A new ContextPresets instance with combined state and disposables.
123
+ """
124
+ return self.__class__(
125
+ name=self.name,
126
+ _state=(*self._state, *other._state),
127
+ _disposables=(*self._disposables, *other._disposables),
128
+ )
129
+
130
+ def with_state(
131
+ self,
132
+ *state: ContextPresetsStatePreparing | State,
133
+ ) -> Self:
134
+ """
135
+ Create a new preset with additional state.
136
+
137
+ Returns a new ContextPresets instance with the provided state objects
138
+ or state factories added to the existing state collection.
139
+
140
+ Parameters
141
+ ----------
142
+ *state : ContextPresetsStatePreparing | State
143
+ Additional state objects or state factory functions to include.
144
+
145
+ Returns
146
+ -------
147
+ Self
148
+ A new ContextPresets instance with the additional state, or the
149
+ same instance if no state was provided.
150
+ """
151
+ if not state:
152
+ return self
153
+
154
+ return self.__class__(
155
+ name=self.name,
156
+ _state=(*self._state, *state),
157
+ _disposables=self._disposables,
158
+ )
159
+
160
+ def with_disposable(
161
+ self,
162
+ *disposable: ContextPresetsDisposablesPreparing,
163
+ ) -> Self:
164
+ """
165
+ Create a new preset with additional disposables.
166
+
167
+ Returns a new ContextPresets instance with the provided disposable
168
+ factory functions added to the existing disposables collection.
169
+
170
+ Parameters
171
+ ----------
172
+ *disposable : ContextPresetsDisposablesPreparing
173
+ Additional disposable factory functions to include.
174
+
175
+ Returns
176
+ -------
177
+ Self
178
+ A new ContextPresets instance with the additional disposables, or the
179
+ same instance if no disposables were provided.
180
+ """
181
+ if not disposable:
182
+ return self
183
+
184
+ return self.__class__(
185
+ name=self.name,
186
+ _state=self._state,
187
+ _disposables=(*self._disposables, *disposable),
188
+ )
189
+
190
+ async def prepare(self) -> Disposables:
191
+ """
192
+ Prepare the preset for use by resolving all state and disposables.
193
+
194
+ This method evaluates all state factories and disposable factories to create
195
+ concrete instances. State objects are wrapped in a DisposableState to unify
196
+ the handling of state and disposable resources.
197
+
198
+ The method ensures concurrent safety by creating fresh instances each time
199
+ it's called, making it safe to use the same preset across multiple concurrent
200
+ scopes.
201
+
202
+ Returns
203
+ -------
204
+ Disposables
205
+ A Disposables container holding all resolved state (wrapped in DisposableState)
206
+ and disposable resources from this preset.
207
+
208
+ Note
209
+ ----
210
+ This method is called automatically when using presets with ctx.scope(),
211
+ so you typically don't need to call it directly.
212
+ """
213
+ # Collect states directly
214
+ collected_states: list[State] = []
215
+ for state in self._state:
216
+ if isinstance(state, State):
217
+ collected_states.append(state)
218
+ else:
219
+ resolved_state: Iterable[State] | State = await state()
220
+ if isinstance(resolved_state, State):
221
+ collected_states.append(resolved_state)
222
+
223
+ else:
224
+ collected_states.extend(resolved_state)
225
+
226
+ collected_disposables: list[Disposable]
227
+ if collected_states:
228
+ collected_disposables = [DisposableState(_state=collected_states)]
229
+
230
+ else:
231
+ collected_disposables = []
232
+
233
+ for disposable in self._disposables:
234
+ resolved_disposable: Iterable[Disposable] | Disposable = await disposable()
235
+ if hasattr(resolved_disposable, "__aenter__") and hasattr(
236
+ resolved_disposable, "__aexit__"
237
+ ):
238
+ collected_disposables.append(cast(Disposable, resolved_disposable))
239
+
240
+ else:
241
+ collected_disposables.extend(cast(Iterable[Disposable], resolved_disposable))
242
+
243
+ return Disposables(*collected_disposables)
244
+
245
+
246
+ class DisposableState(Immutable):
247
+ _state: Iterable[State]
248
+
249
+ async def __aenter__(self) -> Iterable[State]:
250
+ return self._state
251
+
252
+ async def __aexit__(
253
+ self,
254
+ exc_type: type[BaseException] | None,
255
+ exc_val: BaseException | None,
256
+ exc_tb: TracebackType | None,
257
+ ) -> None:
258
+ pass
259
+
260
+
261
+ class ContextPresetsRegistry(Immutable):
262
+ _presets: Mapping[str, ContextPresets]
263
+
264
+ def __init__(
265
+ self,
266
+ presets: Collection[ContextPresets],
267
+ ) -> None:
268
+ object.__setattr__(
269
+ self,
270
+ "_presets",
271
+ {preset.name: preset for preset in presets},
272
+ )
273
+
274
+ def select(
275
+ self,
276
+ name: str,
277
+ /,
278
+ ) -> ContextPresets | None:
279
+ return self._presets.get(name)
280
+
281
+
282
+ class ContextPresetsRegistryContext(Immutable):
283
+ _context: ClassVar[ContextVar[ContextPresetsRegistry]] = ContextVar[ContextPresetsRegistry](
284
+ "ContextPresetsRegistryContext"
285
+ )
286
+
287
+ @classmethod
288
+ def select(
289
+ cls,
290
+ name: str,
291
+ /,
292
+ ) -> ContextPresets | None:
293
+ try:
294
+ return cls._context.get().select(name)
295
+
296
+ except LookupError:
297
+ return None # no presets
298
+
299
+ _registry: ContextPresetsRegistry
300
+ _token: Token[ContextPresetsRegistry] | None = None
301
+
302
+ def __init__(
303
+ self,
304
+ registry: ContextPresetsRegistry,
305
+ ) -> None:
306
+ object.__setattr__(
307
+ self,
308
+ "_registry",
309
+ registry,
310
+ )
311
+ object.__setattr__(
312
+ self,
313
+ "_token",
314
+ None,
315
+ )
316
+
317
+ def __enter__(self) -> None:
318
+ assert self._token is None, "Context reentrance is not allowed" # nosec: B101
319
+ object.__setattr__(
320
+ self,
321
+ "_token",
322
+ ContextPresetsRegistryContext._context.set(self._registry),
323
+ )
324
+
325
+ def __exit__(
326
+ self,
327
+ exc_type: type[BaseException] | None,
328
+ exc_val: BaseException | None,
329
+ exc_tb: TracebackType | None,
330
+ ) -> None:
331
+ assert self._token is not None, "Unbalanced context enter/exit" # nosec: B101
332
+ ContextPresetsRegistryContext._context.reset(self._token)
333
+ object.__setattr__(
334
+ self,
335
+ "_token",
336
+ None,
337
+ )
haiway/context/state.py CHANGED
@@ -1,12 +1,12 @@
1
1
  from asyncio import iscoroutinefunction
2
- from collections.abc import Callable, Coroutine, Iterable, MutableMapping
2
+ from collections.abc import Callable, Collection, Coroutine, Iterable, MutableMapping
3
3
  from contextvars import ContextVar, Token
4
4
  from threading import Lock
5
5
  from types import TracebackType
6
- from typing import Any, Self, cast, final, overload
6
+ from typing import Any, ClassVar, Self, cast, overload
7
7
 
8
8
  from haiway.context.types import MissingContext, MissingState
9
- from haiway.state import State
9
+ from haiway.state import Immutable, State
10
10
  from haiway.utils.mimic import mimic_function
11
11
 
12
12
  __all__ = (
@@ -15,8 +15,7 @@ __all__ = (
15
15
  )
16
16
 
17
17
 
18
- @final
19
- class ScopeState:
18
+ class ScopeState(Immutable):
20
19
  """
21
20
  Container for state objects within a scope.
22
21
 
@@ -25,47 +24,24 @@ class ScopeState:
25
24
  This class is immutable after initialization.
26
25
  """
27
26
 
28
- __slots__ = (
29
- "_lock",
30
- "_state",
31
- )
27
+ _state: MutableMapping[type[State], State]
28
+ _lock: Lock
32
29
 
33
30
  def __init__(
34
31
  self,
35
32
  state: Iterable[State],
36
33
  ) -> None:
37
- self._state: MutableMapping[type[State], State]
38
34
  object.__setattr__(
39
35
  self,
40
36
  "_state",
41
37
  {type(element): element for element in state},
42
38
  )
43
- self._lock: Lock
44
39
  object.__setattr__(
45
40
  self,
46
41
  "_lock",
47
42
  Lock(),
48
43
  )
49
44
 
50
- def __setattr__(
51
- self,
52
- name: str,
53
- value: Any,
54
- ) -> Any:
55
- raise AttributeError(
56
- f"Can't modify immutable {self.__class__.__qualname__},"
57
- f" attribute - '{name}' cannot be modified"
58
- )
59
-
60
- def __delattr__(
61
- self,
62
- name: str,
63
- ) -> None:
64
- raise AttributeError(
65
- f"Can't modify immutable {self.__class__.__qualname__},"
66
- f" attribute - '{name}' cannot be deleted"
67
- )
68
-
69
45
  def check_state[StateType: State](
70
46
  self,
71
47
  state: type[StateType],
@@ -183,20 +159,17 @@ class ScopeState:
183
159
  Self
184
160
  A new ScopeState with the combined state
185
161
  """
186
- if state:
187
- return self.__class__(
188
- [
189
- *self._state.values(),
190
- *state,
191
- ]
192
- )
193
-
194
- else:
162
+ # Fast path: if no new state, return self
163
+ state_list = list(state)
164
+ if not state_list:
195
165
  return self
196
166
 
167
+ # Optimize: pre-allocate with known size and use dict comprehension
168
+ combined_state = {**self._state, **{type(s): s for s in state_list}}
169
+ return self.__class__(combined_state.values())
170
+
197
171
 
198
- @final
199
- class StateContext:
172
+ class StateContext(Immutable):
200
173
  """
201
174
  Context manager for state within a scope.
202
175
 
@@ -205,7 +178,26 @@ class StateContext:
205
178
  This class is immutable after initialization.
206
179
  """
207
180
 
208
- _context = ContextVar[ScopeState]("StateContext")
181
+ _context: ClassVar[ContextVar[ScopeState]] = ContextVar[ScopeState]("StateContext")
182
+
183
+ @classmethod
184
+ def current_state(cls) -> Collection[State]:
185
+ """
186
+ Return an immutable snapshot of the current state.
187
+
188
+ Returns
189
+ -------
190
+ Collection[State]
191
+ State objects present in the current context,
192
+ or an empty tuple if no context is active.
193
+ """
194
+ try:
195
+ scope_state: ScopeState = cls._context.get()
196
+ with scope_state._lock:
197
+ return tuple(scope_state._state.values())
198
+
199
+ except LookupError:
200
+ return () # return empty as default
209
201
 
210
202
  @classmethod
211
203
  def check_state[StateType: State](
@@ -306,51 +298,13 @@ class StateContext:
306
298
  """
307
299
  try:
308
300
  # update current scope context
309
- return cls(state=cls._context.get().updated(state=state))
301
+ return cls(_state=cls._context.get().updated(state=state))
310
302
 
311
303
  except LookupError: # create root scope when missing
312
- return cls(state=ScopeState(state))
304
+ return cls(_state=ScopeState(state))
313
305
 
314
- __slots__ = (
315
- "_state",
316
- "_token",
317
- )
318
-
319
- def __init__(
320
- self,
321
- state: ScopeState,
322
- ) -> None:
323
- self._state: ScopeState
324
- object.__setattr__(
325
- self,
326
- "_state",
327
- state,
328
- )
329
- self._token: Token[ScopeState] | None
330
- object.__setattr__(
331
- self,
332
- "_token",
333
- None,
334
- )
335
-
336
- def __setattr__(
337
- self,
338
- name: str,
339
- value: Any,
340
- ) -> Any:
341
- raise AttributeError(
342
- f"Can't modify immutable {self.__class__.__qualname__},"
343
- f" attribute - '{name}' cannot be modified"
344
- )
345
-
346
- def __delattr__(
347
- self,
348
- name: str,
349
- ) -> None:
350
- raise AttributeError(
351
- f"Can't modify immutable {self.__class__.__qualname__},"
352
- f" attribute - '{name}' cannot be deleted"
353
- )
306
+ _state: ScopeState
307
+ _token: Token[ScopeState] | None = None
354
308
 
355
309
  def __enter__(self) -> None:
356
310
  """
haiway/context/tasks.py CHANGED
@@ -2,13 +2,14 @@ from asyncio import Task, TaskGroup, get_event_loop
2
2
  from collections.abc import Callable, Coroutine
3
3
  from contextvars import ContextVar, Token, copy_context
4
4
  from types import TracebackType
5
- from typing import Any, final
5
+ from typing import Any, ClassVar
6
+
7
+ from haiway.state import Immutable
6
8
 
7
9
  __all__ = ("TaskGroupContext",)
8
10
 
9
11
 
10
- @final
11
- class TaskGroupContext:
12
+ class TaskGroupContext(Immutable):
12
13
  """
13
14
  Context manager for managing task groups within a scope.
14
15
 
@@ -17,7 +18,7 @@ class TaskGroupContext:
17
18
  This class is immutable after initialization.
18
19
  """
19
20
 
20
- _context = ContextVar[TaskGroup]("TaskGroupContext")
21
+ _context: ClassVar[ContextVar[TaskGroup]] = ContextVar[TaskGroup]("TaskGroupContext")
21
22
 
22
23
  @classmethod
23
24
  def run[Result, **Arguments](
@@ -59,10 +60,8 @@ class TaskGroupContext:
59
60
  context=copy_context(),
60
61
  )
61
62
 
62
- __slots__ = (
63
- "_group",
64
- "_token",
65
- )
63
+ _group: TaskGroup
64
+ _token: Token[TaskGroup] | None = None
66
65
 
67
66
  def __init__(
68
67
  self,
@@ -76,13 +75,11 @@ class TaskGroupContext:
76
75
  task_group: TaskGroup | None
77
76
  The task group to use, or None to create a new one
78
77
  """
79
- self._group: TaskGroup
80
78
  object.__setattr__(
81
79
  self,
82
80
  "_group",
83
81
  task_group if task_group is not None else TaskGroup(),
84
82
  )
85
- self._token: Token[TaskGroup] | None
86
83
  object.__setattr__(
87
84
  self,
88
85
  "_token",
@@ -243,7 +243,7 @@ def LoggerObservability( # noqa: C901, PLR0915
243
243
  nonlocal root_logger
244
244
  if root_scope is None:
245
245
  root_scope = scope
246
- root_logger = logger or getLogger(scope.label)
246
+ root_logger = logger or getLogger(scope.name)
247
247
 
248
248
  else:
249
249
  scopes[scope.parent_id].nested.append(scope_store)
@@ -251,7 +251,7 @@ def LoggerObservability( # noqa: C901, PLR0915
251
251
  assert root_logger is not None # nosec: B101
252
252
  root_logger.log(
253
253
  ObservabilityLevel.INFO,
254
- f"[{trace_id_hex}] {scope.unique_name} Entering scope: {scope.label}",
254
+ f"[{trace_id_hex}] {scope.unique_name} Entering scope: {scope.name}",
255
255
  )
256
256
 
257
257
  def scope_exiting(
@@ -274,7 +274,7 @@ def LoggerObservability( # noqa: C901, PLR0915
274
274
 
275
275
  root_logger.log(
276
276
  ObservabilityLevel.INFO,
277
- f"[{trace_id_hex}] {scope.unique_name} Exiting scope: {scope.label}",
277
+ f"[{trace_id_hex}] {scope.unique_name} Exiting scope: {scope.name}",
278
278
  )
279
279
  metric_str: str = f"Metric - scope_time:{scopes[scope.scope_id].time:.3f}s"
280
280
  if debug_context: # store only for summary
@@ -332,9 +332,7 @@ def _tree_summary(scope_store: ScopeStore) -> str:
332
332
  str
333
333
  A formatted string representation of the scope hierarchy with recorded events
334
334
  """
335
- elements: list[str] = [
336
- f"┍━ {scope_store.identifier.label} [{scope_store.identifier.scope_id}]:"
337
- ]
335
+ elements: list[str] = [f"┍━ {scope_store.identifier.name} [{scope_store.identifier.scope_id}]:"]
338
336
  for element in scope_store.store:
339
337
  if not element:
340
338
  continue # skip empty
@@ -686,7 +686,7 @@ class OpenTelemetry:
686
686
 
687
687
  scope_store: ScopeStore
688
688
  if root_scope is None:
689
- meter = metrics.get_meter(scope.label)
689
+ meter = metrics.get_meter(scope.name)
690
690
  context: Context = get_current()
691
691
 
692
692
  # Handle distributed tracing with external trace ID
@@ -722,12 +722,12 @@ class OpenTelemetry:
722
722
  scope,
723
723
  context=context,
724
724
  span=tracer.start_span(
725
- name=scope.label,
725
+ name=scope.name,
726
726
  context=context,
727
727
  links=links,
728
728
  ),
729
729
  meter=meter,
730
- logger=get_logger(scope.label),
730
+ logger=get_logger(scope.name),
731
731
  )
732
732
  root_scope = scope
733
733
 
@@ -737,11 +737,11 @@ class OpenTelemetry:
737
737
  scope,
738
738
  context=scopes[scope.parent_id].context,
739
739
  span=tracer.start_span(
740
- name=scope.label,
740
+ name=scope.name,
741
741
  context=scopes[scope.parent_id].context,
742
742
  ),
743
743
  meter=meter,
744
- logger=get_logger(scope.label),
744
+ logger=get_logger(scope.name),
745
745
  )
746
746
  scopes[scope.parent_id].nested.append(scope_store)
747
747
 
haiway/state/__init__.py CHANGED
@@ -1,4 +1,5 @@
1
1
  from haiway.state.attributes import AttributeAnnotation, attribute_annotations
2
+ from haiway.state.immutable import Immutable
2
3
  from haiway.state.path import AttributePath
3
4
  from haiway.state.requirement import AttributeRequirement
4
5
  from haiway.state.structure import State
@@ -7,6 +8,7 @@ __all__ = (
7
8
  "AttributeAnnotation",
8
9
  "AttributePath",
9
10
  "AttributeRequirement",
11
+ "Immutable",
10
12
  "State",
11
13
  "attribute_annotations",
12
14
  )