haiway 0.25.1__py3-none-any.whl → 0.26.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.
haiway/context/access.py CHANGED
@@ -13,6 +13,7 @@ from collections.abc import (
13
13
  Iterable,
14
14
  Mapping,
15
15
  )
16
+ from contextlib import AbstractAsyncContextManager, AbstractContextManager
16
17
  from logging import Logger
17
18
  from types import TracebackType
18
19
  from typing import Any, final, overload
@@ -25,38 +26,28 @@ from haiway.context.observability import (
25
26
  ObservabilityContext,
26
27
  ObservabilityLevel,
27
28
  )
29
+
30
+ # Import after other imports to avoid circular dependencies
28
31
  from haiway.context.presets import (
29
- ContextPresets,
30
- ContextPresetsRegistry,
31
- ContextPresetsRegistryContext,
32
+ ContextPreset,
33
+ ContextPresetRegistryContext,
32
34
  )
33
35
  from haiway.context.state import ScopeState, StateContext
34
36
  from haiway.context.tasks import TaskGroupContext
35
37
  from haiway.state import Immutable, State
38
+ from haiway.utils.collections import as_list
36
39
  from haiway.utils.stream import AsyncStream
37
40
 
38
41
  __all__ = ("ctx",)
39
42
 
40
43
 
41
44
  class ScopeContext(Immutable):
42
- """
43
- Context manager for executing code within a defined scope.
44
-
45
- ScopeContext manages scope-related data and behavior including identity, state,
46
- observability, and task coordination. It enforces immutability and provides both
47
- synchronous and asynchronous context management interfaces.
48
-
49
- This class should not be instantiated directly; use the ctx.scope() factory method
50
- to create scope contexts.
51
- """
52
-
53
45
  _identifier: ScopeIdentifier
54
46
  _state: Collection[State]
55
- _captured_state: Collection[State]
56
- _resolved_state_context: StateContext | None
47
+ _state_context: StateContext | None
57
48
  _disposables: Disposables | None
58
- _presets: ContextPresets | None
59
- _presets_disposables: Disposables | None
49
+ _preset: ContextPreset | None
50
+ _preset_disposables: Disposables | None
60
51
  _observability_context: ObservabilityContext
61
52
  _task_group_context: TaskGroupContext | None
62
53
 
@@ -65,6 +56,7 @@ class ScopeContext(Immutable):
65
56
  name: str,
66
57
  task_group: TaskGroup | None,
67
58
  state: tuple[State, ...],
59
+ preset: ContextPreset | None,
68
60
  disposables: Disposables | None,
69
61
  observability: Observability | Logger | None,
70
62
  ) -> None:
@@ -79,16 +71,10 @@ class ScopeContext(Immutable):
79
71
  "_state",
80
72
  state,
81
73
  )
82
- # capture current contextual state (without new additions)
83
- object.__setattr__(
84
- self,
85
- "_captured_state",
86
- StateContext.current_state(),
87
- )
88
74
  # placeholder for temporary, resolved state context
89
75
  object.__setattr__(
90
76
  self,
91
- "_resolved_state_context",
77
+ "_state_context",
92
78
  None,
93
79
  )
94
80
  object.__setattr__(
@@ -98,12 +84,12 @@ class ScopeContext(Immutable):
98
84
  )
99
85
  object.__setattr__(
100
86
  self,
101
- "_presets",
102
- ContextPresetsRegistryContext.select(name),
87
+ "_preset",
88
+ preset if preset is not None else ContextPresetRegistryContext.select(name),
103
89
  )
104
90
  object.__setattr__(
105
91
  self,
106
- "_presets_disposables",
92
+ "_preset_disposables",
107
93
  None,
108
94
  )
109
95
  object.__setattr__(
@@ -123,80 +109,28 @@ class ScopeContext(Immutable):
123
109
  else None,
124
110
  )
125
111
 
126
- def __enter__(self) -> str:
127
- assert ( # nosec: B101
128
- self._task_group_context is None or self._identifier.is_root
129
- ), "Can't enter synchronous context with task group"
130
- assert self._disposables is None, "Can't enter synchronous context with disposables" # nosec: B101
131
- assert self._resolved_state_context is None # nosec: B101
132
- assert self._presets is None, "Can't enter synchronous context with presets" # nosec: B101
133
- self._identifier.__enter__()
134
- self._observability_context.__enter__()
135
- # For sync context, only use explicit state (no presets or disposables allowed)
136
- resolved_state_context: StateContext = StateContext(
137
- _state=ScopeState((*self._captured_state, *self._state))
138
- )
139
- object.__setattr__(
140
- self,
141
- "_resolved_state_context",
142
- resolved_state_context,
143
- )
144
- resolved_state_context.__enter__()
145
-
146
- return str(self._observability_context.observability.trace_identifying(self._identifier))
147
-
148
- def __exit__(
149
- self,
150
- exc_type: type[BaseException] | None,
151
- exc_val: BaseException | None,
152
- exc_tb: TracebackType | None,
153
- ) -> None:
154
- assert self._resolved_state_context is not None # nosec: B101
155
- self._resolved_state_context.__exit__(
156
- exc_type=exc_type,
157
- exc_val=exc_val,
158
- exc_tb=exc_tb,
159
- )
160
- object.__setattr__(
161
- self,
162
- "_resolved_state_context",
163
- None,
164
- )
165
- self._observability_context.__exit__(
166
- exc_type=exc_type,
167
- exc_val=exc_val,
168
- exc_tb=exc_tb,
169
- )
170
- self._identifier.__exit__(
171
- exc_type=exc_type,
172
- exc_val=exc_val,
173
- exc_tb=exc_tb,
174
- )
175
-
176
112
  async def __aenter__(self) -> str:
177
- assert self._presets_disposables is None # nosec: B101
178
- assert self._resolved_state_context is None # nosec: B101
113
+ assert self._preset_disposables is None # nosec: B101
114
+ assert self._state_context is None # nosec: B101
179
115
  self._identifier.__enter__()
180
116
  self._observability_context.__enter__()
181
117
 
182
- if task_group := self._task_group_context:
183
- await task_group.__aenter__()
118
+ if self._task_group_context is not None:
119
+ await self._task_group_context.__aenter__()
184
120
 
185
121
  # Collect all state sources in priority order (lowest to highest priority)
186
- collected_state: list[State] = []
187
-
188
122
  # 1. Add contextual state first (lowest priority)
189
- collected_state.extend(self._captured_state)
123
+ collected_state: list[State] = as_list(StateContext.current_state())
190
124
 
191
125
  # 2. Add preset state (low priority, overrides contextual)
192
- if self._presets is not None:
193
- presets_disposables: Disposables = await self._presets.prepare()
126
+ if self._preset is not None:
127
+ preset_disposables: Disposables = await self._preset.prepare()
194
128
  object.__setattr__(
195
129
  self,
196
- "_presets_disposables",
197
- presets_disposables,
130
+ "_preset_disposables",
131
+ preset_disposables,
198
132
  )
199
- collected_state.extend(await presets_disposables.prepare())
133
+ collected_state.extend(await preset_disposables.prepare())
200
134
 
201
135
  # 3. Add explicit disposables state (medium priority)
202
136
  if self._disposables is not None:
@@ -212,7 +146,7 @@ class ScopeContext(Immutable):
212
146
  resolved_state_context.__enter__()
213
147
  object.__setattr__(
214
148
  self,
215
- "_resolved_state_context",
149
+ "_state_context",
216
150
  resolved_state_context,
217
151
  )
218
152
 
@@ -224,7 +158,7 @@ class ScopeContext(Immutable):
224
158
  exc_val: BaseException | None,
225
159
  exc_tb: TracebackType | None,
226
160
  ) -> None:
227
- assert self._resolved_state_context is not None # nosec: B101
161
+ assert self._state_context is not None # nosec: B101
228
162
  if self._disposables is not None:
229
163
  await self._disposables.dispose(
230
164
  exc_type=exc_type,
@@ -232,42 +166,40 @@ class ScopeContext(Immutable):
232
166
  exc_tb=exc_tb,
233
167
  )
234
168
 
235
- if self._presets_disposables is not None:
236
- await self._presets_disposables.dispose(
169
+ if self._preset_disposables is not None:
170
+ await self._preset_disposables.dispose(
237
171
  exc_type=exc_type,
238
172
  exc_val=exc_val,
239
173
  exc_tb=exc_tb,
240
174
  )
241
175
  object.__setattr__(
242
176
  self,
243
- "_presets_disposables",
177
+ "_preset_disposables",
244
178
  None,
245
179
  )
246
180
 
247
- if task_group := self._task_group_context:
248
- await task_group.__aexit__(
181
+ if self._task_group_context is not None:
182
+ await self._task_group_context.__aexit__(
249
183
  exc_type=exc_type,
250
184
  exc_val=exc_val,
251
185
  exc_tb=exc_tb,
252
186
  )
253
187
 
254
- self._resolved_state_context.__exit__(
188
+ self._state_context.__exit__(
255
189
  exc_type=exc_type,
256
190
  exc_val=exc_val,
257
191
  exc_tb=exc_tb,
258
192
  )
259
193
  object.__setattr__(
260
194
  self,
261
- "_resolved_state_context",
195
+ "_state_context",
262
196
  None,
263
197
  )
264
-
265
198
  self._observability_context.__exit__(
266
199
  exc_type=exc_type,
267
200
  exc_val=exc_val,
268
201
  exc_tb=exc_tb,
269
202
  )
270
-
271
203
  self._identifier.__exit__(
272
204
  exc_type=exc_type,
273
205
  exc_val=exc_val,
@@ -276,65 +208,32 @@ class ScopeContext(Immutable):
276
208
 
277
209
 
278
210
  class DisposablesContext(Immutable):
279
- """
280
- Immutable async context manager for managing collections of disposables.
281
-
282
- DisposablesContext captures the current contextual state upon initialization
283
- and provides an async context manager interface for managing disposable resources.
284
- When entered, it prepares the disposables to collect their state, merges it with
285
- the captured state, and creates a resolved StateContext. Upon exit, it properly
286
- disposes of the disposables and cleans up internal references.
287
-
288
- This class should not be instantiated directly; use the ctx.disposables() factory
289
- method to create disposables contexts.
290
- """
291
-
292
211
  _disposables: Disposables
293
- _captured_state: Collection[State]
294
- _resolved_state_context: StateContext | None
212
+ _state_context: StateContext | None
295
213
 
296
214
  def __init__(
297
215
  self,
298
216
  disposables: Disposables,
299
217
  ) -> None:
300
- # capture current contextual state (without new additions)
301
218
  object.__setattr__(
302
219
  self,
303
- "_captured_state",
304
- StateContext.current_state(),
220
+ "_disposables",
221
+ disposables,
305
222
  )
306
- # placeholder for temporary, resolved state context
307
223
  object.__setattr__(
308
224
  self,
309
- "_resolved_state_context",
225
+ "_state_context",
310
226
  None,
311
227
  )
312
- object.__setattr__(
313
- self,
314
- "_disposables",
315
- disposables,
316
- )
317
228
 
318
229
  async def __aenter__(self) -> None:
319
- assert self._resolved_state_context is None # nosec: B101
320
-
321
- # Collect all state sources in priority order (lowest to highest priority)
322
- collected_state: list[State] = []
323
-
324
- collected_state.extend(self._captured_state)
325
-
326
- collected_state.extend(await self._disposables.prepare())
327
-
328
- # Create resolved state context with all collected state
329
- resolved_state_context: StateContext = StateContext(
330
- _state=ScopeState(tuple(collected_state))
331
- )
332
-
333
- resolved_state_context.__enter__()
230
+ assert self._state_context is None # nosec: B101
231
+ state_context: StateContext = StateContext.updated(await self._disposables.prepare())
232
+ state_context.__enter__()
334
233
  object.__setattr__(
335
234
  self,
336
- "_resolved_state_context",
337
- resolved_state_context,
235
+ "_state_context",
236
+ state_context,
338
237
  )
339
238
 
340
239
  async def __aexit__(
@@ -343,22 +242,20 @@ class DisposablesContext(Immutable):
343
242
  exc_val: BaseException | None,
344
243
  exc_tb: TracebackType | None,
345
244
  ) -> None:
346
- assert self._resolved_state_context is not None # nosec: B101
347
-
245
+ assert self._state_context is not None # nosec: B101
348
246
  await self._disposables.dispose(
349
247
  exc_type=exc_type,
350
248
  exc_val=exc_val,
351
249
  exc_tb=exc_tb,
352
250
  )
353
-
354
- self._resolved_state_context.__exit__(
251
+ self._state_context.__exit__(
355
252
  exc_type=exc_type,
356
253
  exc_val=exc_val,
357
254
  exc_tb=exc_tb,
358
255
  )
359
256
  object.__setattr__(
360
257
  self,
361
- "_resolved_state_context",
258
+ "_state_context",
362
259
  None,
363
260
  )
364
261
 
@@ -406,8 +303,8 @@ class ctx:
406
303
 
407
304
  @staticmethod
408
305
  def presets(
409
- *presets: ContextPresets,
410
- ) -> ContextPresetsRegistryContext:
306
+ *presets: ContextPreset,
307
+ ) -> AbstractContextManager[None]:
411
308
  """
412
309
  Create a context manager for a preset registry.
413
310
 
@@ -419,15 +316,19 @@ class ctx:
419
316
  for use with ctx.scope(). The presets are looked up by their name when
420
317
  creating scopes.
421
318
 
319
+ Note: For single preset usage, consider passing the preset directly to
320
+ ctx.scope() using the preset parameter instead of using this registry.
321
+ Presets only work with async contexts.
322
+
422
323
  Parameters
423
324
  ----------
424
- *presets: ContextPresets
325
+ *presets: ContextPreset
425
326
  Variable number of preset configurations to register. Each preset
426
327
  must have a unique name within the registry.
427
328
 
428
329
  Returns
429
330
  -------
430
- ContextPresetsRegistryContext
331
+ AbstractContextManager[None]
431
332
  A context manager that makes the presets available in nested scopes
432
333
 
433
334
  Examples
@@ -435,19 +336,19 @@ class ctx:
435
336
  Basic preset usage:
436
337
 
437
338
  >>> from haiway import ctx, State
438
- >>> from haiway.context import ContextPresets
339
+ >>> from haiway.context import ContextPreset
439
340
  >>>
440
341
  >>> class ApiConfig(State):
441
342
  ... base_url: str
442
343
  ... timeout: int = 30
443
344
  >>>
444
345
  >>> # Define presets
445
- >>> dev_preset = ContextPresets(
346
+ >>> dev_preset = ContextPreset(
446
347
  ... name="development",
447
348
  ... _state=[ApiConfig(base_url="https://dev-api.example.com")]
448
349
  ... )
449
350
  >>>
450
- >>> prod_preset = ContextPresets(
351
+ >>> prod_preset = ContextPreset(
451
352
  ... name="production",
452
353
  ... _state=[ApiConfig(base_url="https://api.example.com", timeout=60)]
453
354
  ... )
@@ -461,7 +362,7 @@ class ctx:
461
362
  Nested preset registries:
462
363
 
463
364
  >>> base_presets = [dev_preset, prod_preset]
464
- >>> override_preset = ContextPresets(
365
+ >>> override_preset = ContextPreset(
465
366
  ... name="development",
466
367
  ... _state=[ApiConfig(base_url="https://staging.example.com")]
467
368
  ... )
@@ -476,27 +377,29 @@ class ctx:
476
377
 
477
378
  See Also
478
379
  --------
479
- ContextPresets : For creating individual preset configurations
380
+ ContextPreset : For creating individual preset configurations
480
381
  ctx.scope : For creating scopes that can use presets
481
382
  """
482
- return ContextPresetsRegistryContext(
483
- registry=ContextPresetsRegistry(
484
- presets=presets,
485
- ),
486
- )
383
+ return ContextPresetRegistryContext(presets=presets)
487
384
 
488
385
  @staticmethod
489
386
  def scope(
490
387
  name: str,
491
388
  /,
492
389
  *state: State | None,
390
+ preset: ContextPreset | None = None,
493
391
  disposables: Disposables | Iterable[Disposable] | None = None,
494
392
  task_group: TaskGroup | None = None,
495
393
  observability: Observability | Logger | None = None,
496
- ) -> ScopeContext:
394
+ ) -> AbstractAsyncContextManager[str]:
497
395
  """
498
- Prepare scope context with given parameters. When called within an existing context\
499
- it becomes nested with current context as its parent.
396
+ Prepare scope context with given parameters.
397
+
398
+ When called within an existing context, it becomes nested with current context
399
+ as its parent.
400
+
401
+ Note: Presets can only be used with async contexts. Synchronous contexts
402
+ do not support preset functionality.
500
403
 
501
404
  State Priority System
502
405
  ---------------------
@@ -516,28 +419,71 @@ class ctx:
516
419
  name of the scope context, can be associated with state presets
517
420
 
518
421
  *state: State | None
519
- state propagated within the scope context, will be merged with current state by\
520
- replacing current with provided on conflict.
422
+ state propagated within the scope context, will be merged with current state by
423
+ replacing current with provided on conflict.
424
+
425
+ preset: ContextPreset | None = None
426
+ context preset to be used within the scope context. The preset's state and
427
+ disposables will be applied to the scope with lower priority than explicit state.
428
+ Only works with async contexts.
521
429
 
522
430
  disposables: Disposables | Iterable[Disposable] | None
523
- disposables consumed within the context when entered. Produced state will automatically\
524
- be added to the scope state. Using asynchronous context is required if any disposables\
525
- were provided.
431
+ disposables consumed within the context when entered. Produced state will automatically
432
+ be added to the scope state. Using asynchronous context is required if any disposables
433
+ were provided.
526
434
 
527
435
  task_group: TaskGroup | None
528
436
  task group used for spawning and joining tasks within the context. Root scope will
529
- always have task group created even when not set.
437
+ always have task group created even when not set.
530
438
 
531
439
  observability: Observability | Logger | None = None
532
- observability solution responsible for recording and storing metrics, logs and events.\
533
- Assigning observability within existing context will result in an error.
440
+ observability solution responsible for recording and storing metrics, logs and events.
441
+ Assigning observability within existing context will result in an error.
534
442
  When not provided, logger with the scope name will be requested and used.
535
443
 
536
444
  Returns
537
445
  -------
538
- ScopeContext
539
- context object intended to enter context manager with.\
540
- context manager will provide trace_id of current context.
446
+ AbstractAsyncContextManager[str]
447
+ context manager object intended to enter the scope with.
448
+ context manager will provide trace_id of current scope.
449
+
450
+ Examples
451
+ --------
452
+ Using a preset directly:
453
+
454
+ >>> from haiway import ctx, State
455
+ >>> from haiway.context import ContextPreset
456
+ >>>
457
+ >>> class ApiConfig(State):
458
+ ... base_url: str
459
+ ... timeout: int = 30
460
+ >>>
461
+ >>> api_preset = ContextPreset(
462
+ ... name="api",
463
+ ... state=[ApiConfig(base_url="https://api.example.com")]
464
+ ... )
465
+ >>>
466
+ >>> # Direct preset usage
467
+ >>> async with ctx.scope("main", preset=api_preset):
468
+ ... config = ctx.state(ApiConfig)
469
+ ... # Uses preset configuration
470
+ >>>
471
+ >>> # Override preset state with explicit state
472
+ >>> async with ctx.scope("main", ApiConfig(timeout=60), preset=api_preset):
473
+ ... config = ctx.state(ApiConfig)
474
+ ... # base_url from preset, timeout overridden to 60
475
+
476
+ Using preset registry (original approach):
477
+
478
+ >>> # Multiple presets registered
479
+ >>> with ctx.presets(dev_preset, prod_preset):
480
+ ... async with ctx.scope("development"): # Matches dev_preset by name
481
+ ... config = ctx.state(ApiConfig)
482
+
483
+ See Also
484
+ --------
485
+ ctx.presets : For registering multiple presets by name
486
+ ContextPreset : For creating preset configurations
541
487
  """
542
488
 
543
489
  resolved_disposables: Disposables | None
@@ -555,6 +501,7 @@ class ctx:
555
501
  name=name,
556
502
  task_group=task_group,
557
503
  state=tuple(element for element in state if element is not None),
504
+ preset=preset,
558
505
  disposables=resolved_disposables,
559
506
  observability=observability,
560
507
  )
@@ -562,21 +509,23 @@ class ctx:
562
509
  @staticmethod
563
510
  def updated(
564
511
  *state: State | None,
565
- ) -> StateContext:
512
+ ) -> AbstractContextManager[None]:
566
513
  """
567
- Update scope context with given state. When called within an existing context\
568
- it becomes nested with current context as its predecessor.
514
+ Update scope context with given state.
515
+
516
+ When called within an existing context, it becomes nested with current
517
+ context as its predecessor.
569
518
 
570
519
  Parameters
571
520
  ----------
572
521
  *state: State | None
573
- state propagated within the updated scope context, will be merged with current if any\
574
- by replacing current with provided on conflict
522
+ state propagated within the updated scope context, will be merged with current if any
523
+ by replacing current with provided on conflict
575
524
 
576
525
  Returns
577
526
  -------
578
- StateContext
579
- state part of context object intended to enter context manager with it
527
+ AbstractContextManager[None]
528
+ context manager object intended to enter updated state context with it
580
529
  """
581
530
 
582
531
  return StateContext.updated(element for element in state if element is not None)
@@ -584,7 +533,7 @@ class ctx:
584
533
  @staticmethod
585
534
  def disposables(
586
535
  *disposables: Disposable | None,
587
- ) -> DisposablesContext:
536
+ ) -> AbstractAsyncContextManager[None]:
588
537
  """
589
538
  Create a container for managing multiple disposable resources.
590
539
 
@@ -601,9 +550,9 @@ class ctx:
601
550
 
602
551
  Returns
603
552
  -------
604
- DisposableContext
605
- A container that manages the lifecycle of all provided disposables
606
- and propagates their state to the context as when used with ctx.scope()
553
+ AbstractAsyncContextManager[None]
554
+ A context manager that manages the lifecycle of all provided disposables
555
+ and propagates their state to the context, similar to ctx.scope()
607
556
 
608
557
  Examples
609
558
  --------
@@ -634,8 +583,9 @@ class ctx:
634
583
  **kwargs: Arguments.kwargs,
635
584
  ) -> Task[Result]:
636
585
  """
637
- Spawn an async task within current scope context task group. When called outside of context\
638
- it will spawn detached task instead.
586
+ Spawn an async task within current scope context task group.
587
+
588
+ When called outside of context, it will spawn detached task instead.
639
589
 
640
590
  Parameters
641
591
  ----------
@@ -684,7 +634,7 @@ class ctx:
684
634
  """
685
635
 
686
636
  output_stream = AsyncStream[Element]()
687
- stream_scope: ScopeContext = ctx.scope("stream")
637
+ stream_scope: AbstractAsyncContextManager[str] = ctx.scope("stream")
688
638
 
689
639
  async def stream() -> None:
690
640
  async with stream_scope:
@@ -845,8 +795,9 @@ class ctx:
845
795
  **extra: Any,
846
796
  ) -> None:
847
797
  """
848
- Log using ERROR level within current scope context. When there is no current scope\
849
- root logger will be used without additional details.
798
+ Log using ERROR level within current scope context.
799
+
800
+ When there is no current scope, root logger will be used without additional details.
850
801
 
851
802
  Parameters
852
803
  ----------
@@ -881,8 +832,9 @@ class ctx:
881
832
  **extra: Any,
882
833
  ) -> None:
883
834
  """
884
- Log using WARNING level within current scope context. When there is no current scope\
885
- root logger will be used without additional details.
835
+ Log using WARNING level within current scope context.
836
+
837
+ When there is no current scope, root logger will be used without additional details.
886
838
 
887
839
  Parameters
888
840
  ----------
@@ -916,8 +868,9 @@ class ctx:
916
868
  **extra: Any,
917
869
  ) -> None:
918
870
  """
919
- Log using INFO level within current scope context. When there is no current scope\
920
- root logger will be used without additional details.
871
+ Log using INFO level within current scope context.
872
+
873
+ When there is no current scope, root logger will be used without additional details.
921
874
 
922
875
  Parameters
923
876
  ----------
@@ -949,8 +902,9 @@ class ctx:
949
902
  **extra: Any,
950
903
  ) -> None:
951
904
  """
952
- Log using DEBUG level within current scope context. When there is no current scope\
953
- root logger will be used without additional details.
905
+ Log using DEBUG level within current scope context.
906
+
907
+ When there is no current scope, root logger will be used without additional details.
954
908
 
955
909
  Parameters
956
910
  ----------