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.
haiway/__init__.py CHANGED
@@ -1,4 +1,5 @@
1
1
  from haiway.context import (
2
+ ContextPresets,
2
3
  Disposable,
3
4
  Disposables,
4
5
  MissingContext,
@@ -26,7 +27,7 @@ from haiway.helpers import (
26
27
  timeout,
27
28
  traced,
28
29
  )
29
- from haiway.state import AttributePath, AttributeRequirement, State
30
+ from haiway.state import AttributePath, AttributeRequirement, Immutable, State
30
31
  from haiway.types import (
31
32
  MISSING,
32
33
  Default,
@@ -64,12 +65,14 @@ __all__ = (
64
65
  "AsyncStream",
65
66
  "AttributePath",
66
67
  "AttributeRequirement",
68
+ "ContextPresets",
67
69
  "Default",
68
70
  "DefaultValue",
69
71
  "Disposable",
70
72
  "Disposables",
71
73
  "File",
72
74
  "FileAccess",
75
+ "Immutable",
73
76
  "LoggerObservability",
74
77
  "Missing",
75
78
  "MissingContext",
@@ -14,12 +14,16 @@ from haiway.context.observability import (
14
14
  ObservabilityScopeExiting,
15
15
  ObservabilityTraceIdentifying,
16
16
  )
17
+ from haiway.context.presets import ContextPresets
17
18
  from haiway.context.state import StateContext
18
19
  from haiway.context.types import MissingContext, MissingState
20
+ from haiway.state import Immutable
19
21
 
20
22
  __all__ = (
23
+ "ContextPresets",
21
24
  "Disposable",
22
25
  "Disposables",
26
+ "Immutable",
23
27
  "MissingContext",
24
28
  "MissingState",
25
29
  "Observability",
haiway/context/access.py CHANGED
@@ -8,6 +8,7 @@ from collections.abc import (
8
8
  AsyncGenerator,
9
9
  AsyncIterator,
10
10
  Callable,
11
+ Collection,
11
12
  Coroutine,
12
13
  Iterable,
13
14
  Mapping,
@@ -24,16 +25,20 @@ from haiway.context.observability import (
24
25
  ObservabilityContext,
25
26
  ObservabilityLevel,
26
27
  )
28
+ from haiway.context.presets import (
29
+ ContextPresets,
30
+ ContextPresetsRegistry,
31
+ ContextPresetsRegistryContext,
32
+ )
27
33
  from haiway.context.state import ScopeState, StateContext
28
34
  from haiway.context.tasks import TaskGroupContext
29
- from haiway.state import State
35
+ from haiway.state import Immutable, State
30
36
  from haiway.utils.stream import AsyncStream
31
37
 
32
38
  __all__ = ("ctx",)
33
39
 
34
40
 
35
- @final
36
- class ScopeContext:
41
+ class ScopeContext(Immutable):
37
42
  """
38
43
  Context manager for executing code within a defined scope.
39
44
 
@@ -45,42 +50,62 @@ class ScopeContext:
45
50
  to create scope contexts.
46
51
  """
47
52
 
48
- __slots__ = (
49
- "_disposables",
50
- "_identifier",
51
- "_observability_context",
52
- "_state_context",
53
- "_task_group_context",
54
- )
53
+ _identifier: ScopeIdentifier
54
+ _state: Collection[State]
55
+ _captured_state: Collection[State]
56
+ _resolved_state_context: StateContext | None
57
+ _disposables: Disposables | None
58
+ _presets: ContextPresets | None
59
+ _presets_disposables: Disposables | None
60
+ _observability_context: ObservabilityContext
61
+ _task_group_context: TaskGroupContext | None
55
62
 
56
63
  def __init__(
57
64
  self,
58
- label: str,
65
+ name: str,
59
66
  task_group: TaskGroup | None,
60
67
  state: tuple[State, ...],
61
68
  disposables: Disposables | None,
62
69
  observability: Observability | Logger | None,
63
70
  ) -> None:
64
- self._identifier: ScopeIdentifier
65
71
  object.__setattr__(
66
72
  self,
67
73
  "_identifier",
68
- ScopeIdentifier.scope(label),
74
+ ScopeIdentifier.scope(name),
69
75
  )
70
- # prepare state context to capture current state
71
- self._state_context: StateContext
76
+ # store explicit state separately for priority control
72
77
  object.__setattr__(
73
78
  self,
74
- "_state_context",
75
- StateContext.updated(state),
79
+ "_state",
80
+ state,
81
+ )
82
+ # capture current contextual state (without new additions)
83
+ object.__setattr__(
84
+ self,
85
+ "_captured_state",
86
+ StateContext.current_state(),
87
+ )
88
+ # placeholder for temporary, resolved state context
89
+ object.__setattr__(
90
+ self,
91
+ "_resolved_state_context",
92
+ None,
76
93
  )
77
- self._disposables: Disposables | None
78
94
  object.__setattr__(
79
95
  self,
80
96
  "_disposables",
81
97
  disposables,
82
98
  )
83
- self._observability_context: ObservabilityContext
99
+ object.__setattr__(
100
+ self,
101
+ "_presets",
102
+ ContextPresetsRegistryContext.select(name),
103
+ )
104
+ object.__setattr__(
105
+ self,
106
+ "_presets_disposables",
107
+ None,
108
+ )
84
109
  object.__setattr__(
85
110
  self,
86
111
  "_observability_context",
@@ -90,7 +115,6 @@ class ScopeContext:
90
115
  observability=observability,
91
116
  ),
92
117
  )
93
- self._task_group_context: TaskGroupContext | None
94
118
  object.__setattr__(
95
119
  self,
96
120
  "_task_group_context",
@@ -99,35 +123,27 @@ class ScopeContext:
99
123
  else None,
100
124
  )
101
125
 
102
- def __setattr__(
103
- self,
104
- name: str,
105
- value: Any,
106
- ) -> Any:
107
- raise AttributeError(
108
- f"Can't modify immutable {self.__class__.__qualname__},"
109
- f" attribute - '{name}' cannot be modified"
110
- )
111
-
112
- def __delattr__(
113
- self,
114
- name: str,
115
- ) -> None:
116
- raise AttributeError(
117
- f"Can't modify immutable {self.__class__.__qualname__},"
118
- f" attribute - '{name}' cannot be deleted"
119
- )
120
-
121
126
  def __enter__(self) -> str:
122
127
  assert ( # nosec: B101
123
128
  self._task_group_context is None or self._identifier.is_root
124
129
  ), "Can't enter synchronous context with task group"
125
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
126
133
  self._identifier.__enter__()
127
134
  self._observability_context.__enter__()
128
- self._state_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__()
129
145
 
130
- return self._observability_context.observability.trace_identifying(self._identifier).hex
146
+ return str(self._observability_context.observability.trace_identifying(self._identifier))
131
147
 
132
148
  def __exit__(
133
149
  self,
@@ -135,11 +151,17 @@ class ScopeContext:
135
151
  exc_val: BaseException | None,
136
152
  exc_tb: TracebackType | None,
137
153
  ) -> None:
138
- self._state_context.__exit__(
154
+ assert self._resolved_state_context is not None # nosec: B101
155
+ self._resolved_state_context.__exit__(
139
156
  exc_type=exc_type,
140
157
  exc_val=exc_val,
141
158
  exc_tb=exc_tb,
142
159
  )
160
+ object.__setattr__(
161
+ self,
162
+ "_resolved_state_context",
163
+ None,
164
+ )
143
165
  self._observability_context.__exit__(
144
166
  exc_type=exc_type,
145
167
  exc_val=exc_val,
@@ -152,31 +174,49 @@ class ScopeContext:
152
174
  )
153
175
 
154
176
  async def __aenter__(self) -> str:
177
+ assert self._presets_disposables is None # nosec: B101
178
+ assert self._resolved_state_context is None # nosec: B101
155
179
  self._identifier.__enter__()
156
180
  self._observability_context.__enter__()
157
181
 
158
182
  if task_group := self._task_group_context:
159
183
  await task_group.__aenter__()
160
184
 
161
- # lazily initialize state to include disposables results
162
- if disposables := self._disposables:
163
- assert self._state_context._token is None # nosec: B101
185
+ # Collect all state sources in priority order (lowest to highest priority)
186
+ collected_state: list[State] = []
187
+
188
+ # 1. Add contextual state first (lowest priority)
189
+ collected_state.extend(self._captured_state)
190
+
191
+ # 2. Add preset state (low priority, overrides contextual)
192
+ if self._presets is not None:
193
+ presets_disposables: Disposables = await self._presets.prepare()
164
194
  object.__setattr__(
165
195
  self,
166
- "_state_context",
167
- StateContext(
168
- state=ScopeState(
169
- (
170
- *self._state_context._state._state.values(),
171
- *await disposables.prepare(),
172
- )
173
- ),
174
- ),
196
+ "_presets_disposables",
197
+ presets_disposables,
175
198
  )
199
+ collected_state.extend(await presets_disposables.prepare())
176
200
 
177
- self._state_context.__enter__()
201
+ # 3. Add explicit disposables state (medium priority)
202
+ if self._disposables is not None:
203
+ collected_state.extend(await self._disposables.prepare())
178
204
 
179
- return self._observability_context.observability.trace_identifying(self._identifier).hex
205
+ # 4. Add explicit state last (highest priority)
206
+ collected_state.extend(self._state)
207
+ # Create resolved state context with all collected state
208
+ resolved_state_context: StateContext = StateContext(
209
+ _state=ScopeState(tuple(collected_state))
210
+ )
211
+
212
+ resolved_state_context.__enter__()
213
+ object.__setattr__(
214
+ self,
215
+ "_resolved_state_context",
216
+ resolved_state_context,
217
+ )
218
+
219
+ return str(self._observability_context.observability.trace_identifying(self._identifier))
180
220
 
181
221
  async def __aexit__(
182
222
  self,
@@ -184,13 +224,26 @@ class ScopeContext:
184
224
  exc_val: BaseException | None,
185
225
  exc_tb: TracebackType | None,
186
226
  ) -> None:
187
- if disposables := self._disposables:
188
- await disposables.dispose(
227
+ assert self._resolved_state_context is not None # nosec: B101
228
+ if self._disposables is not None:
229
+ await self._disposables.dispose(
189
230
  exc_type=exc_type,
190
231
  exc_val=exc_val,
191
232
  exc_tb=exc_tb,
192
233
  )
193
234
 
235
+ if self._presets_disposables is not None:
236
+ await self._presets_disposables.dispose(
237
+ exc_type=exc_type,
238
+ exc_val=exc_val,
239
+ exc_tb=exc_tb,
240
+ )
241
+ object.__setattr__(
242
+ self,
243
+ "_presets_disposables",
244
+ None,
245
+ )
246
+
194
247
  if task_group := self._task_group_context:
195
248
  await task_group.__aexit__(
196
249
  exc_type=exc_type,
@@ -198,11 +251,16 @@ class ScopeContext:
198
251
  exc_tb=exc_tb,
199
252
  )
200
253
 
201
- self._state_context.__exit__(
254
+ self._resolved_state_context.__exit__(
202
255
  exc_type=exc_type,
203
256
  exc_val=exc_val,
204
257
  exc_tb=exc_tb,
205
258
  )
259
+ object.__setattr__(
260
+ self,
261
+ "_resolved_state_context",
262
+ None,
263
+ )
206
264
 
207
265
  self._observability_context.__exit__(
208
266
  exc_type=exc_type,
@@ -217,6 +275,94 @@ class ScopeContext:
217
275
  )
218
276
 
219
277
 
278
+ 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
+ _disposables: Disposables
293
+ _captured_state: Collection[State]
294
+ _resolved_state_context: StateContext | None
295
+
296
+ def __init__(
297
+ self,
298
+ disposables: Disposables,
299
+ ) -> None:
300
+ # capture current contextual state (without new additions)
301
+ object.__setattr__(
302
+ self,
303
+ "_captured_state",
304
+ StateContext.current_state(),
305
+ )
306
+ # placeholder for temporary, resolved state context
307
+ object.__setattr__(
308
+ self,
309
+ "_resolved_state_context",
310
+ None,
311
+ )
312
+ object.__setattr__(
313
+ self,
314
+ "_disposables",
315
+ disposables,
316
+ )
317
+
318
+ 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__()
334
+ object.__setattr__(
335
+ self,
336
+ "_resolved_state_context",
337
+ resolved_state_context,
338
+ )
339
+
340
+ async def __aexit__(
341
+ self,
342
+ exc_type: type[BaseException] | None,
343
+ exc_val: BaseException | None,
344
+ exc_tb: TracebackType | None,
345
+ ) -> None:
346
+ assert self._resolved_state_context is not None # nosec: B101
347
+
348
+ await self._disposables.dispose(
349
+ exc_type=exc_type,
350
+ exc_val=exc_val,
351
+ exc_tb=exc_tb,
352
+ )
353
+
354
+ self._resolved_state_context.__exit__(
355
+ exc_type=exc_type,
356
+ exc_val=exc_val,
357
+ exc_tb=exc_tb,
358
+ )
359
+ object.__setattr__(
360
+ self,
361
+ "_resolved_state_context",
362
+ None,
363
+ )
364
+
365
+
220
366
  @final
221
367
  class ctx:
222
368
  """
@@ -258,9 +404,90 @@ class ctx:
258
404
  """
259
405
  return ObservabilityContext.trace_id(scope_identifier)
260
406
 
407
+ @staticmethod
408
+ def presets(
409
+ *presets: ContextPresets,
410
+ ) -> ContextPresetsRegistryContext:
411
+ """
412
+ Create a context manager for a preset registry.
413
+
414
+ This method creates a registry of context presets that can be used within
415
+ nested scopes. Presets allow you to define reusable combinations of state
416
+ and disposables that can be referenced by name when creating scopes.
417
+
418
+ When entering this context manager, the provided presets become available
419
+ for use with ctx.scope(). The presets are looked up by their name when
420
+ creating scopes.
421
+
422
+ Parameters
423
+ ----------
424
+ *presets: ContextPresets
425
+ Variable number of preset configurations to register. Each preset
426
+ must have a unique name within the registry.
427
+
428
+ Returns
429
+ -------
430
+ ContextPresetsRegistryContext
431
+ A context manager that makes the presets available in nested scopes
432
+
433
+ Examples
434
+ --------
435
+ Basic preset usage:
436
+
437
+ >>> from haiway import ctx, State
438
+ >>> from haiway.context import ContextPresets
439
+ >>>
440
+ >>> class ApiConfig(State):
441
+ ... base_url: str
442
+ ... timeout: int = 30
443
+ >>>
444
+ >>> # Define presets
445
+ >>> dev_preset = ContextPresets(
446
+ ... name="development",
447
+ ... _state=[ApiConfig(base_url="https://dev-api.example.com")]
448
+ ... )
449
+ >>>
450
+ >>> prod_preset = ContextPresets(
451
+ ... name="production",
452
+ ... _state=[ApiConfig(base_url="https://api.example.com", timeout=60)]
453
+ ... )
454
+ >>>
455
+ >>> # Use presets
456
+ >>> with ctx.presets(dev_preset, prod_preset):
457
+ ... async with ctx.scope("development"):
458
+ ... config = ctx.state(ApiConfig)
459
+ ... assert config.base_url == "https://dev-api.example.com"
460
+
461
+ Nested preset registries:
462
+
463
+ >>> base_presets = [dev_preset, prod_preset]
464
+ >>> override_preset = ContextPresets(
465
+ ... name="development",
466
+ ... _state=[ApiConfig(base_url="https://staging.example.com")]
467
+ ... )
468
+ >>>
469
+ >>> with ctx.presets(*base_presets):
470
+ ... # Outer registry has dev and prod presets
471
+ ... with ctx.presets(override_preset):
472
+ ... # Inner registry overrides dev preset
473
+ ... async with ctx.scope("development"):
474
+ ... config = ctx.state(ApiConfig)
475
+ ... assert config.base_url == "https://staging.example.com"
476
+
477
+ See Also
478
+ --------
479
+ ContextPresets : For creating individual preset configurations
480
+ ctx.scope : For creating scopes that can use presets
481
+ """
482
+ return ContextPresetsRegistryContext(
483
+ registry=ContextPresetsRegistry(
484
+ presets=presets,
485
+ ),
486
+ )
487
+
261
488
  @staticmethod
262
489
  def scope(
263
- label: str,
490
+ name: str,
264
491
  /,
265
492
  *state: State | None,
266
493
  disposables: Disposables | Iterable[Disposable] | None = None,
@@ -271,10 +498,22 @@ class ctx:
271
498
  Prepare scope context with given parameters. When called within an existing context\
272
499
  it becomes nested with current context as its parent.
273
500
 
501
+ State Priority System
502
+ ---------------------
503
+ State resolution follows a 4-layer priority system (highest to lowest):
504
+
505
+ 1. **Explicit state** (passed to ctx.scope()) - HIGHEST priority
506
+ 2. **Explicit disposables** (passed to ctx.scope()) - medium priority
507
+ 3. **Preset state** (from presets) - low priority
508
+ 4. **Contextual state** (from parent contexts) - LOWEST priority
509
+
510
+ When state types conflict, higher priority sources override lower priority ones.
511
+ State objects are resolved by type, with the highest priority instance winning.
512
+
274
513
  Parameters
275
514
  ----------
276
- label: str
277
- name of the scope context
515
+ name: str
516
+ name of the scope context, can be associated with state presets
278
517
 
279
518
  *state: State | None
280
519
  state propagated within the scope context, will be merged with current state by\
@@ -313,7 +552,7 @@ class ctx:
313
552
  resolved_disposables = Disposables(*iterable)
314
553
 
315
554
  return ScopeContext(
316
- label=label,
555
+ name=name,
317
556
  task_group=task_group,
318
557
  state=tuple(element for element in state if element is not None),
319
558
  disposables=resolved_disposables,
@@ -345,7 +584,7 @@ class ctx:
345
584
  @staticmethod
346
585
  def disposables(
347
586
  *disposables: Disposable | None,
348
- ) -> Disposables:
587
+ ) -> DisposablesContext:
349
588
  """
350
589
  Create a container for managing multiple disposable resources.
351
590
 
@@ -362,9 +601,9 @@ class ctx:
362
601
 
363
602
  Returns
364
603
  -------
365
- Disposables
604
+ DisposableContext
366
605
  A container that manages the lifecycle of all provided disposables
367
- and propagates their state to the context when used with ctx.scope()
606
+ and propagates their state to the context as when used with ctx.scope()
368
607
 
369
608
  Examples
370
609
  --------
@@ -373,16 +612,19 @@ class ctx:
373
612
  >>> from haiway import ctx
374
613
  >>> async def main():
375
614
  ...
376
- ... async with ctx.scope(
377
- ... "database_work",
378
- ... disposables=(database_connection(),)
615
+ ... async with ctx.disposables(
616
+ ... database_connection(),
379
617
  ... ):
380
618
  ... # ConnectionState is now available in context
381
619
  ... conn_state = ctx.state(ConnectionState)
382
620
  ... await conn_state.connection.execute("SELECT 1")
383
621
  """
384
622
 
385
- return Disposables(*(disposable for disposable in disposables if disposable is not None))
623
+ return DisposablesContext(
624
+ disposables=Disposables(
625
+ *(disposable for disposable in disposables if disposable is not None)
626
+ )
627
+ )
386
628
 
387
629
  @staticmethod
388
630
  def spawn[Result, **Arguments](