haiway 0.21.4__py3-none-any.whl → 0.22.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
@@ -13,6 +13,8 @@ from haiway.context import (
13
13
  ctx,
14
14
  )
15
15
  from haiway.helpers import (
16
+ File,
17
+ FileAccess,
16
18
  LoggerObservability,
17
19
  asynchronous,
18
20
  cache,
@@ -63,6 +65,8 @@ __all__ = (
63
65
  "DefaultValue",
64
66
  "Disposable",
65
67
  "Disposables",
68
+ "File",
69
+ "FileAccess",
66
70
  "LoggerObservability",
67
71
  "Missing",
68
72
  "MissingContext",
haiway/context/access.py CHANGED
@@ -169,8 +169,8 @@ class ScopeContext:
169
169
  StateContext(
170
170
  state=ScopeState(
171
171
  (
172
- *await disposables.__aenter__(),
173
172
  *self._state_context._state._state.values(),
173
+ *await disposables.prepare(),
174
174
  )
175
175
  ),
176
176
  ),
@@ -187,7 +187,7 @@ class ScopeContext:
187
187
  exc_tb: TracebackType | None,
188
188
  ) -> None:
189
189
  if disposables := self._disposables:
190
- await disposables.__aexit__(
190
+ await disposables.dispose(
191
191
  exc_type=exc_type,
192
192
  exc_val=exc_val,
193
193
  exc_tb=exc_tb,
@@ -382,6 +382,48 @@ class ctx:
382
382
 
383
383
  return StateContext.updated(element for element in state if element is not None)
384
384
 
385
+ @staticmethod
386
+ def disposables(
387
+ *disposables: Disposable | None,
388
+ ) -> Disposables:
389
+ """
390
+ Create a container for managing multiple disposable resources.
391
+
392
+ Disposables are async context managers that can provide state objects and
393
+ require proper cleanup. This method creates a Disposables container that
394
+ manages multiple disposable resources as a single unit, handling their
395
+ lifecycle and state propagation.
396
+
397
+ Parameters
398
+ ----------
399
+ *disposables: Disposable | None
400
+ Variable number of disposable resources to be managed together.
401
+ None values are filtered out automatically.
402
+
403
+ Returns
404
+ -------
405
+ Disposables
406
+ A container that manages the lifecycle of all provided disposables
407
+ and propagates their state to the context when used with ctx.scope()
408
+
409
+ Examples
410
+ --------
411
+ Using disposables with database connections:
412
+
413
+ >>> from haiway import ctx
414
+ >>> async def main():
415
+ ...
416
+ ... async with ctx.scope(
417
+ ... "database_work",
418
+ ... disposables=(database_connection(),)
419
+ ... ):
420
+ ... # ConnectionState is now available in context
421
+ ... conn_state = ctx.state(ConnectionState)
422
+ ... await conn_state.connection.execute("SELECT 1")
423
+ """
424
+
425
+ return Disposables(*(disposable for disposable in disposables if disposable is not None))
426
+
385
427
  @staticmethod
386
428
  def spawn[Result, **Arguments](
387
429
  function: Callable[Arguments, Coroutine[Any, Any, Result]],
@@ -493,6 +535,37 @@ class ctx:
493
535
  else:
494
536
  raise RuntimeError("Attempting to cancel context out of asyncio task")
495
537
 
538
+ @staticmethod
539
+ def check_state[StateType: State](
540
+ state: type[StateType],
541
+ /,
542
+ *,
543
+ instantiate_defaults: bool = False,
544
+ ) -> bool:
545
+ """
546
+ Check if state object is available in the current context.
547
+
548
+ Verifies if state object of the specified type is available the current context.
549
+ Instantiates requested state if needed and possible.
550
+
551
+ Parameters
552
+ ----------
553
+ state: type[StateType]
554
+ The type of state to check
555
+
556
+ instantiate_defaults: bool = False
557
+ Control if default value should be instantiated during check.
558
+
559
+ Returns
560
+ -------
561
+ bool
562
+ True if state is available, otherwise False.
563
+ """
564
+ return StateContext.check_state(
565
+ state,
566
+ instantiate_defaults=instantiate_defaults,
567
+ )
568
+
496
569
  @staticmethod
497
570
  def state[StateType: State](
498
571
  state: type[StateType],
@@ -500,18 +573,60 @@ class ctx:
500
573
  default: StateType | None = None,
501
574
  ) -> StateType:
502
575
  """
503
- Access current scope context state by its type. If there is no matching state defined\
504
- default value will be created if able, an exception will raise otherwise.
576
+ Access state from the current scope context by its type.
577
+
578
+ Retrieves state objects that have been propagated within the current execution context.
579
+ State objects are automatically made available through context scopes and disposables.
580
+ If no matching state is found, creates a default instance if possible.
505
581
 
506
582
  Parameters
507
583
  ----------
508
584
  state: type[StateType]
509
- type of requested state
585
+ The State class type to retrieve from the current context
586
+ default: StateType | None, default=None
587
+ Optional default instance to return if state is not found in context.
588
+ If None and no state is found, a new instance will be created if possible.
510
589
 
511
590
  Returns
512
591
  -------
513
592
  StateType
514
- resolved state instance
593
+ The state instance from the current context or a default/new instance
594
+
595
+ Raises
596
+ ------
597
+ RuntimeError
598
+ If called outside of any scope context
599
+ TypeError
600
+ If no state is found and no default can be created
601
+
602
+ Examples
603
+ --------
604
+ Accessing configuration state:
605
+
606
+ >>> from haiway import ctx, State
607
+ >>>
608
+ >>> class ApiConfig(State):
609
+ ... base_url: str = "https://api.example.com"
610
+ ... timeout: int = 30
611
+ >>>
612
+ >>> async def fetch_data():
613
+ ... config = ctx.state(ApiConfig)
614
+ ... # Use config.base_url and config.timeout
615
+ >>>
616
+ >>> async with ctx.scope("api", ApiConfig(base_url="https://custom.api.com")):
617
+ ... await fetch_data() # Uses custom config
618
+
619
+ Accessing state with default:
620
+
621
+ >>> cache_config = ctx.state(CacheConfig, default=CacheConfig(ttl=3600))
622
+
623
+ Within service classes:
624
+
625
+ >>> class UserService(State):
626
+ ... @classmethod
627
+ ... async def get_user(cls, user_id: str) -> User:
628
+ ... config = ctx.state(DatabaseConfig)
629
+ ... # Use config to connect to database
515
630
  """
516
631
  return StateContext.state(
517
632
  state,
@@ -1,11 +1,20 @@
1
- from asyncio import gather
2
- from collections.abc import Iterable
1
+ from asyncio import (
2
+ AbstractEventLoop,
3
+ gather,
4
+ get_running_loop,
5
+ iscoroutinefunction,
6
+ run_coroutine_threadsafe,
7
+ wrap_future,
8
+ )
9
+ from collections.abc import Callable, Coroutine, Iterable
3
10
  from contextlib import AbstractAsyncContextManager
4
11
  from itertools import chain
5
12
  from types import TracebackType
6
13
  from typing import Any, final
7
14
 
15
+ from haiway.context.state import StateContext
8
16
  from haiway.state import State
17
+ from haiway.utils.mimic import mimic_function
9
18
 
10
19
  __all__ = (
11
20
  "Disposable",
@@ -14,10 +23,37 @@ __all__ = (
14
23
 
15
24
  type Disposable = AbstractAsyncContextManager[Iterable[State] | State | None]
16
25
  """
17
- A type alias for asynchronous context managers that can be disposed.
26
+ A type alias for asynchronous context managers that provide disposable resources.
18
27
 
19
28
  Represents an asynchronous resource that needs proper cleanup when no longer needed.
20
- When entered, it may return State instances that will be propagated to the context.
29
+ When entered, it may return State instances that will be automatically propagated
30
+ to the current context. The resource is guaranteed to be properly disposed of
31
+ when the context exits, even if exceptions occur.
32
+
33
+ Type Details
34
+ ------------
35
+ - Must be an async context manager (implements __aenter__ and __aexit__)
36
+ - Can return None, a single State instance, or multiple State instances
37
+ - State instances are automatically added to the context scope
38
+ - Cleanup is handled automatically when the context exits
39
+
40
+ Examples
41
+ --------
42
+ Creating a disposable database connection:
43
+
44
+ >>> import contextlib
45
+ >>> from haiway import State
46
+ >>>
47
+ >>> class DatabaseState(State):
48
+ ... connection: DatabaseConnection
49
+ ...
50
+ >>> @contextlib.asynccontextmanager
51
+ >>> async def database_disposable():
52
+ ... connection = await create_database_connection()
53
+ ... try:
54
+ ... yield DatabaseState(connection=connection)
55
+ ... finally:
56
+ ... await connection.close()
21
57
  """
22
58
 
23
59
 
@@ -27,14 +63,60 @@ class Disposables:
27
63
  A container for multiple Disposable resources that manages their lifecycle.
28
64
 
29
65
  This class provides a way to handle multiple disposable resources as a single unit,
30
- entering all of them when the container is entered and exiting all of them when
31
- the container is exited. Any states returned by the disposables are collected
32
- and returned as a unified collection.
66
+ entering all of them in parallel when the container is entered and exiting all of
67
+ them when the container is exited. Any states returned by the disposables are
68
+ collected and automatically propagated to the context.
69
+
70
+ Key Features
71
+ ------------
72
+ - Parallel setup and cleanup of all contained disposables
73
+ - Automatic state collection and context propagation
74
+ - Thread-safe cross-event-loop disposal
75
+ - Exception handling with BaseExceptionGroup for multiple failures
76
+ - Immutable after initialization
77
+
78
+ The class is designed to work seamlessly with ctx.scope() and ensures proper
79
+ resource cleanup even when exceptions occur during setup or teardown.
33
80
 
34
- The class is immutable after initialization.
81
+ Examples
82
+ --------
83
+ Creating and using multiple disposables:
84
+
85
+ >>> from haiway import ctx
86
+ >>> async def main():
87
+ ... disposables = Disposables(
88
+ ... database_disposable(),
89
+ ... cache_disposable()
90
+ ... )
91
+ ...
92
+ ... async with ctx.scope("app", disposables=disposables):
93
+ ... # Both DatabaseState and CacheState are available
94
+ ... db = ctx.state(DatabaseState)
95
+ ... cache = ctx.state(CacheState)
96
+
97
+ Direct context manager usage:
98
+
99
+ >>> async def process_data():
100
+ ... disposables = Disposables(
101
+ ... create_temp_file_disposable(),
102
+ ... create_network_connection_disposable()
103
+ ... )
104
+ ...
105
+ ... async with disposables:
106
+ ... # Resources are set up in parallel
107
+ ... temp_file = ctx.state(TempFileState)
108
+ ... network = ctx.state(NetworkState)
109
+ ...
110
+ ... # Process data using both resources
111
+ ...
112
+ ... # All resources cleaned up automatically
35
113
  """
36
114
 
37
- __slots__ = ("_disposables",)
115
+ __slots__ = (
116
+ "_disposables",
117
+ "_loop",
118
+ "_state_context",
119
+ )
38
120
 
39
121
  def __init__(
40
122
  self,
@@ -54,6 +136,18 @@ class Disposables:
54
136
  "_disposables",
55
137
  disposables,
56
138
  )
139
+ self._state_context: StateContext | None
140
+ object.__setattr__(
141
+ self,
142
+ "_state_context",
143
+ None,
144
+ )
145
+ self._loop: AbstractEventLoop | None
146
+ object.__setattr__(
147
+ self,
148
+ "_loop",
149
+ None,
150
+ )
57
151
 
58
152
  def __setattr__(
59
153
  self,
@@ -85,7 +179,7 @@ class Disposables:
85
179
  """
86
180
  return len(self._disposables) > 0
87
181
 
88
- async def _initialize(
182
+ async def _setup(
89
183
  self,
90
184
  disposable: Disposable,
91
185
  /,
@@ -100,31 +194,69 @@ class Disposables:
100
194
  case multiple:
101
195
  return multiple
102
196
 
103
- async def __aenter__(self) -> Iterable[State]:
197
+ async def prepare(self) -> Iterable[State]:
104
198
  """
105
199
  Enter all contained disposables asynchronously.
106
200
 
107
201
  Enters all disposables in parallel and collects any State objects they return.
108
-
109
- Returns
110
- -------
111
- Iterable[State]
112
- Collection of State objects from all disposables.
113
202
  """
114
- return [
115
- *chain.from_iterable(
116
- state
117
- for state in await gather(
118
- *[self._initialize(disposable) for disposable in self._disposables],
203
+ assert self._loop is None # nosec: B101
204
+ object.__setattr__(
205
+ self,
206
+ "_loop",
207
+ get_running_loop(),
208
+ )
209
+
210
+ return tuple(
211
+ chain.from_iterable(
212
+ await gather(
213
+ *[self._setup(disposable) for disposable in self._disposables],
119
214
  )
120
215
  )
121
- ]
216
+ )
122
217
 
123
- async def __aexit__(
218
+ async def __aenter__(self) -> None:
219
+ """
220
+ Enter all contained disposables asynchronously.
221
+
222
+ Enters all disposables in parallel and collects any State objects they return updating
223
+ current state context.
224
+ """
225
+
226
+ assert self._state_context is None, "Context reentrance is not allowed" # nosec: B101
227
+ state_context = StateContext.updated(await self.prepare())
228
+ state_context.__enter__()
229
+ object.__setattr__(
230
+ self,
231
+ "_state_context",
232
+ state_context,
233
+ )
234
+
235
+ async def _cleanup(
124
236
  self,
237
+ /,
125
238
  exc_type: type[BaseException] | None,
126
239
  exc_val: BaseException | None,
127
240
  exc_tb: TracebackType | None,
241
+ ) -> list[bool | BaseException | None]:
242
+ return await gather(
243
+ *[
244
+ disposable.__aexit__(
245
+ exc_type,
246
+ exc_val,
247
+ exc_tb,
248
+ )
249
+ for disposable in self._disposables
250
+ ],
251
+ return_exceptions=True,
252
+ )
253
+
254
+ async def dispose(
255
+ self,
256
+ /,
257
+ exc_type: type[BaseException] | None = None,
258
+ exc_val: BaseException | None = None,
259
+ exc_tb: TracebackType | None = None,
128
260
  ) -> None:
129
261
  """
130
262
  Exit all contained disposables asynchronously.
@@ -146,19 +278,107 @@ class Disposables:
146
278
  BaseExceptionGroup
147
279
  If multiple disposables raise exceptions during exit
148
280
  """
149
- results: list[bool | BaseException | None] = await gather(
150
- *[
151
- disposable.__aexit__(
281
+ assert self._loop is not None # nosec: B101
282
+ results: list[bool | BaseException | None]
283
+
284
+ try:
285
+ current_loop: AbstractEventLoop = get_running_loop()
286
+ if self._loop != current_loop:
287
+ results = await wrap_future(
288
+ run_coroutine_threadsafe(
289
+ self._cleanup(
290
+ exc_type,
291
+ exc_val,
292
+ exc_tb,
293
+ ),
294
+ loop=self._loop,
295
+ )
296
+ )
297
+
298
+ else:
299
+ results = await self._cleanup(
152
300
  exc_type,
153
301
  exc_val,
154
302
  exc_tb,
155
303
  )
156
- for disposable in self._disposables
157
- ],
158
- return_exceptions=True,
159
- )
304
+
305
+ finally:
306
+ object.__setattr__(
307
+ self,
308
+ "_loop",
309
+ None,
310
+ )
160
311
 
161
312
  exceptions: list[BaseException] = [exc for exc in results if isinstance(exc, BaseException)]
162
313
 
163
- if len(exceptions) > 1:
164
- raise BaseExceptionGroup("Disposing errors", exceptions)
314
+ match len(exceptions):
315
+ case 0:
316
+ return
317
+
318
+ case 1:
319
+ raise exceptions[0]
320
+
321
+ case _:
322
+ raise BaseExceptionGroup("Disposables cleanup errors", exceptions)
323
+
324
+ async def __aexit__(
325
+ self,
326
+ exc_type: type[BaseException] | None,
327
+ exc_val: BaseException | None,
328
+ exc_tb: TracebackType | None,
329
+ ) -> None:
330
+ """
331
+ Exit all contained disposables asynchronously.
332
+
333
+ Properly disposes of all resources by calling their __aexit__ methods in parallel.
334
+ If multiple disposables raise exceptions, they are collected into a BaseExceptionGroup.
335
+ Additionally, produced state context will be also exited resetting state to previous.
336
+
337
+ Parameters
338
+ ----------
339
+ exc_type: type[BaseException] | None
340
+ The type of exception that caused the context to be exited
341
+ exc_val: BaseException | None
342
+ The exception that caused the context to be exited
343
+ exc_tb: TracebackType | None
344
+ The traceback for the exception that caused the context to be exited
345
+
346
+ Raises
347
+ ------
348
+ BaseExceptionGroup
349
+ If multiple disposables raise exceptions during exit
350
+ """
351
+ assert self._state_context is not None, "Unbalanced context enter/exit" # nosec: B101
352
+ try:
353
+ self._state_context.__exit__(
354
+ exc_type,
355
+ exc_val,
356
+ exc_tb,
357
+ )
358
+ object.__setattr__(
359
+ self,
360
+ "_state_context",
361
+ None,
362
+ )
363
+
364
+ finally:
365
+ await self.dispose(
366
+ exc_type,
367
+ exc_val,
368
+ exc_tb,
369
+ )
370
+
371
+ def __call__[Result, **Arguments](
372
+ self,
373
+ function: Callable[Arguments, Coroutine[Any, Any, Result]],
374
+ ) -> Callable[Arguments, Coroutine[Any, Any, Result]]:
375
+ assert iscoroutinefunction(function) # nosec: B101
376
+
377
+ async def async_context(
378
+ *args: Arguments.args,
379
+ **kwargs: Arguments.kwargs,
380
+ ) -> Result:
381
+ async with self:
382
+ return await function(*args, **kwargs)
383
+
384
+ return mimic_function(function, within=async_context)
haiway/context/state.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from asyncio import iscoroutinefunction
2
2
  from collections.abc import Callable, Coroutine, Iterable, MutableMapping
3
3
  from contextvars import ContextVar, Token
4
+ from threading import Lock
4
5
  from types import TracebackType
5
6
  from typing import Any, Self, cast, final, overload
6
7
 
@@ -24,7 +25,7 @@ class ScopeState:
24
25
  This class is immutable after initialization.
25
26
  """
26
27
 
27
- __slots__ = ("_state",)
28
+ __slots__ = ("_lock", "_state")
28
29
 
29
30
  def __init__(
30
31
  self,
@@ -36,6 +37,12 @@ class ScopeState:
36
37
  "_state",
37
38
  {type(element): element for element in state},
38
39
  )
40
+ self._lock: Lock
41
+ object.__setattr__(
42
+ self,
43
+ "_lock",
44
+ Lock(),
45
+ )
39
46
 
40
47
  def __setattr__(
41
48
  self,
@@ -56,6 +63,51 @@ class ScopeState:
56
63
  f" attribute - '{name}' cannot be deleted"
57
64
  )
58
65
 
66
+ def check_state[StateType: State](
67
+ self,
68
+ state: type[StateType],
69
+ /,
70
+ *,
71
+ instantiate_defaults: bool = False,
72
+ ) -> bool:
73
+ """
74
+ Check state object availability by its type.
75
+
76
+ If the state type is not found, attempts to instantiate a new instance of\
77
+ the type if possible.
78
+
79
+ Parameters
80
+ ----------
81
+ state: type[StateType]
82
+ The type of state to check
83
+
84
+ instantiate_defaults: bool = False
85
+ Control if default value should be instantiated during check.
86
+
87
+ Returns
88
+ -------
89
+ bool
90
+ True if state is available, otherwise False.
91
+ """
92
+ if state in self._state:
93
+ return True
94
+
95
+ elif instantiate_defaults:
96
+ with self._lock:
97
+ if state in self._state:
98
+ return True
99
+
100
+ try:
101
+ initialized: StateType = state()
102
+ self._state[state] = initialized
103
+ return True
104
+
105
+ except BaseException:
106
+ return False # unavailable, we don't care the exception
107
+
108
+ else:
109
+ return False
110
+
59
111
  def state[StateType: State](
60
112
  self,
61
113
  state: type[StateType],
@@ -93,17 +145,20 @@ class ScopeState:
93
145
  return default
94
146
 
95
147
  else:
96
- try:
97
- initialized: StateType = state()
98
- # we would need a locking here in multithreaded environment
99
- self._state[state] = initialized
100
- return initialized
101
-
102
- except Exception as exc:
103
- raise MissingState(
104
- f"{state.__qualname__} is not defined in current scope"
105
- " and failed to provide a default value"
106
- ) from exc
148
+ with self._lock:
149
+ if state in self._state:
150
+ return cast(StateType, self._state[state])
151
+
152
+ try:
153
+ initialized: StateType = state()
154
+ self._state[state] = initialized
155
+ return initialized
156
+
157
+ except Exception as exc:
158
+ raise MissingState(
159
+ f"{state.__qualname__} is not defined in current scope"
160
+ " and failed to provide a default value"
161
+ ) from exc
107
162
 
108
163
  def updated(
109
164
  self,
@@ -149,6 +204,40 @@ class StateContext:
149
204
 
150
205
  _context = ContextVar[ScopeState]("StateContext")
151
206
 
207
+ @classmethod
208
+ def check_state[StateType: State](
209
+ cls,
210
+ state: type[StateType],
211
+ /,
212
+ instantiate_defaults: bool = False,
213
+ ) -> bool:
214
+ """
215
+ Check if state object is available in the current context.
216
+
217
+ Verifies if state object of the specified type is available the current context.
218
+
219
+ Parameters
220
+ ----------
221
+ state: type[StateType]
222
+ The type of state to check
223
+
224
+ instantiate_defaults: bool = False
225
+ Control if default value should be instantiated during check.
226
+
227
+ Returns
228
+ -------
229
+ bool
230
+ True if state is available, otherwise False.
231
+ """
232
+ try:
233
+ return cls._context.get().check_state(
234
+ state,
235
+ instantiate_defaults=instantiate_defaults,
236
+ )
237
+
238
+ except LookupError:
239
+ return False # no context no state
240
+
152
241
  @classmethod
153
242
  def state[StateType: State](
154
243
  cls,
@@ -1,16 +1,19 @@
1
1
  from haiway.helpers.asynchrony import asynchronous
2
2
  from haiway.helpers.caching import CacheMakeKey, CacheRead, CacheWrite, cache
3
3
  from haiway.helpers.concurrent import process_concurrently
4
+ from haiway.helpers.files import File, FileAccess
4
5
  from haiway.helpers.observability import LoggerObservability
5
6
  from haiway.helpers.retries import retry
6
7
  from haiway.helpers.throttling import throttle
7
- from haiway.helpers.timeouted import timeout
8
+ from haiway.helpers.timeouting import timeout
8
9
  from haiway.helpers.tracing import traced
9
10
 
10
11
  __all__ = (
11
12
  "CacheMakeKey",
12
13
  "CacheRead",
13
14
  "CacheWrite",
15
+ "File",
16
+ "FileAccess",
14
17
  "LoggerObservability",
15
18
  "asynchronous",
16
19
  "cache",
@@ -11,12 +11,10 @@ __all__ = ("asynchronous",)
11
11
 
12
12
 
13
13
  @overload
14
- def asynchronous[**Args, Result]() -> (
15
- Callable[
16
- [Callable[Args, Result]],
17
- Callable[Args, Coroutine[Any, Any, Result]],
18
- ]
19
- ): ...
14
+ def asynchronous[**Args, Result]() -> Callable[
15
+ [Callable[Args, Result]],
16
+ Callable[Args, Coroutine[Any, Any, Result]],
17
+ ]: ...
20
18
 
21
19
 
22
20
  @overload
@@ -0,0 +1,421 @@
1
+ try:
2
+ import fcntl
3
+
4
+ except ModuleNotFoundError: # Windows does not supprt fcntl
5
+ fcntl = None
6
+
7
+ import os
8
+ from asyncio import Lock
9
+ from pathlib import Path
10
+ from types import TracebackType
11
+ from typing import Protocol, runtime_checkable
12
+
13
+ from haiway.context import ctx
14
+ from haiway.helpers.asynchrony import asynchronous
15
+ from haiway.state import State
16
+
17
+ __all__ = (
18
+ "File",
19
+ "FileAccess",
20
+ "FileAccessing",
21
+ "FileContext",
22
+ "FileException",
23
+ "FileReading",
24
+ "FileWriting",
25
+ )
26
+
27
+
28
+ class FileException(Exception):
29
+ """
30
+ Exception raised for file operation errors.
31
+
32
+ Raised when file operations fail, such as attempting to access
33
+ a non-existent file without the create flag, or when file I/O
34
+ operations encounter errors.
35
+ """
36
+
37
+
38
+ @runtime_checkable
39
+ class FileReading(Protocol):
40
+ """
41
+ Protocol for asynchronous file reading operations.
42
+
43
+ Implementations read the entire file contents and return them as bytes.
44
+ The file position is managed internally and reading always returns the
45
+ complete file contents from the beginning.
46
+ """
47
+
48
+ async def __call__(
49
+ self,
50
+ ) -> bytes: ...
51
+
52
+
53
+ @runtime_checkable
54
+ class FileWriting(Protocol):
55
+ """
56
+ Protocol for asynchronous file writing operations.
57
+
58
+ Implementations write the provided content to the file, completely
59
+ replacing any existing content. The write operation is atomic and
60
+ includes proper synchronization to ensure data is written to disk.
61
+ """
62
+
63
+ async def __call__(
64
+ self,
65
+ content: bytes,
66
+ ) -> None: ...
67
+
68
+
69
+ class File(State):
70
+ """
71
+ State container for file operations within a context scope.
72
+
73
+ Provides access to file operations after a file has been opened using
74
+ FileAccess within a context scope. Follows Haiway's pattern of accessing
75
+ functionality through class methods that retrieve state from the current context.
76
+
77
+ The file operations are provided through the reading and writing protocol
78
+ implementations, which are injected when the file is opened.
79
+ """
80
+
81
+ @classmethod
82
+ async def read(
83
+ cls,
84
+ ) -> bytes:
85
+ """
86
+ Read the complete contents of the file.
87
+
88
+ Returns
89
+ -------
90
+ bytes
91
+ The complete file contents as bytes
92
+
93
+ Raises
94
+ ------
95
+ FileException
96
+ If no file is currently open in the context
97
+ """
98
+ return await ctx.state(cls).reading()
99
+
100
+ @classmethod
101
+ async def write(
102
+ cls,
103
+ content: bytes,
104
+ /,
105
+ ) -> None:
106
+ """
107
+ Write content to the file, replacing existing content.
108
+
109
+ Parameters
110
+ ----------
111
+ content : bytes
112
+ The bytes content to write to the file
113
+
114
+ Raises
115
+ ------
116
+ FileException
117
+ If no file is currently open in the context
118
+ """
119
+ await ctx.state(cls).writing(content)
120
+
121
+ reading: FileReading
122
+ writing: FileWriting
123
+
124
+
125
+ @runtime_checkable
126
+ class FileContext(Protocol):
127
+ """
128
+ Protocol for file context managers.
129
+
130
+ Defines the interface for file context managers that handle the opening,
131
+ access, and cleanup of file resources. Implementations ensure proper
132
+ resource management and make file operations available through the File
133
+ state class.
134
+
135
+ The context manager pattern ensures that file handles are properly closed
136
+ and locks are released even if exceptions occur during file operations.
137
+ """
138
+
139
+ async def __aenter__(self) -> File:
140
+ """
141
+ Enter the file context and return file operations.
142
+
143
+ Returns
144
+ -------
145
+ File
146
+ A File state instance configured for the opened file
147
+
148
+ Raises
149
+ ------
150
+ FileException
151
+ If the file cannot be opened
152
+ """
153
+ ...
154
+
155
+ async def __aexit__(
156
+ self,
157
+ exc_type: type[BaseException] | None,
158
+ exc_val: BaseException | None,
159
+ exc_tb: TracebackType | None,
160
+ ) -> bool | None:
161
+ """
162
+ Exit the file context and clean up resources.
163
+
164
+ Parameters
165
+ ----------
166
+ exc_type : type[BaseException] | None
167
+ The exception type if an exception occurred
168
+ exc_val : BaseException | None
169
+ The exception value if an exception occurred
170
+ exc_tb : TracebackType | None
171
+ The exception traceback if an exception occurred
172
+
173
+ Returns
174
+ -------
175
+ bool | None
176
+ None to allow exceptions to propagate
177
+ """
178
+ ...
179
+
180
+
181
+ @runtime_checkable
182
+ class FileAccessing(Protocol):
183
+ """
184
+ Protocol for file access implementations.
185
+
186
+ Defines the interface for creating file context managers with specific
187
+ access patterns. Implementations handle the details of file opening,
188
+ locking, and resource management.
189
+ """
190
+
191
+ async def __call__(
192
+ self,
193
+ path: Path | str,
194
+ create: bool,
195
+ exclusive: bool,
196
+ ) -> FileContext: ...
197
+
198
+
199
+ @asynchronous
200
+ def _open_file_handle(
201
+ path: Path,
202
+ *,
203
+ create: bool,
204
+ exclusive: bool,
205
+ ) -> int:
206
+ file_handle: int
207
+ if path.exists():
208
+ file_handle = os.open(path, os.O_RDWR)
209
+
210
+ elif create:
211
+ path.parent.mkdir(parents=True, exist_ok=True)
212
+ file_handle = os.open(path, os.O_RDWR | os.O_CREAT)
213
+
214
+ else:
215
+ raise FileException(f"File does not exist: {path}")
216
+
217
+ if exclusive and fcntl is not None:
218
+ fcntl.flock(file_handle, fcntl.LOCK_EX) # pyright: ignore[reportAttributeAccessIssue] # windows
219
+
220
+ return file_handle
221
+
222
+
223
+ @asynchronous
224
+ def _read_file_contents(
225
+ file_handle: int,
226
+ *,
227
+ path: Path,
228
+ ) -> bytes:
229
+ os.lseek(file_handle, 0, os.SEEK_SET)
230
+ bytes_remaining: int = path.stat().st_size
231
+
232
+ # Read all bytes, handling partial reads
233
+ chunks: list[bytes] = []
234
+ while bytes_remaining > 0:
235
+ chunk: bytes = os.read(file_handle, bytes_remaining)
236
+ if not chunk:
237
+ raise FileException("Unexpected end of file during read")
238
+
239
+ bytes_remaining -= len(chunk)
240
+ chunks.append(chunk)
241
+
242
+ return b"".join(chunks)
243
+
244
+
245
+ @asynchronous
246
+ def _write_file_contents(
247
+ file_handle: int,
248
+ *,
249
+ content: bytes,
250
+ ) -> None:
251
+ os.lseek(file_handle, 0, os.SEEK_SET)
252
+
253
+ # Write all bytes, handling partial writes
254
+ offset: int = 0
255
+ while offset < len(content):
256
+ bytes_written: int = os.write(file_handle, content[offset:])
257
+ if bytes_written == 0:
258
+ raise FileException("Failed to write file content")
259
+
260
+ offset += bytes_written
261
+
262
+ os.ftruncate(file_handle, len(content))
263
+ os.fsync(file_handle)
264
+
265
+
266
+ @asynchronous
267
+ def _close_file_handle(
268
+ file_handle: int,
269
+ *,
270
+ exclusive: bool,
271
+ ) -> None:
272
+ if exclusive and fcntl is not None:
273
+ fcntl.flock(file_handle, fcntl.LOCK_UN) # pyright: ignore[reportAttributeAccessIssue] # windows
274
+
275
+ os.close(file_handle)
276
+
277
+
278
+ async def _file_access_context(
279
+ path: Path | str,
280
+ create: bool,
281
+ exclusive: bool,
282
+ ) -> FileContext:
283
+ file_path: Path = Path(path)
284
+
285
+ class FileAccessContext:
286
+ __slots__ = (
287
+ "_file_handle",
288
+ "_file_path",
289
+ "_lock",
290
+ )
291
+
292
+ def __init__(
293
+ self,
294
+ path: Path | str,
295
+ ) -> None:
296
+ self._file_handle: int | None = None
297
+ self._file_path: Path = Path(path)
298
+ self._lock: Lock = Lock()
299
+
300
+ async def __aenter__(self) -> File:
301
+ assert self._file_handle is None # nosec: B101
302
+
303
+ self._file_handle = await _open_file_handle(
304
+ file_path,
305
+ create=create,
306
+ exclusive=exclusive,
307
+ )
308
+
309
+ async def read_file() -> bytes:
310
+ assert self._file_handle is not None # nosec: B101
311
+ async with self._lock:
312
+ return await _read_file_contents(
313
+ self._file_handle,
314
+ path=file_path,
315
+ )
316
+
317
+ async def write_file(content: bytes) -> None:
318
+ assert self._file_handle is not None # nosec: B101
319
+ async with self._lock:
320
+ await _write_file_contents(
321
+ self._file_handle,
322
+ content=content,
323
+ )
324
+
325
+ return File(
326
+ reading=read_file,
327
+ writing=write_file,
328
+ )
329
+
330
+ async def __aexit__(
331
+ self,
332
+ exc_type: type[BaseException] | None,
333
+ exc_val: BaseException | None,
334
+ exc_tb: TracebackType | None,
335
+ ) -> bool | None:
336
+ assert self._file_handle is not None # nosec: B101
337
+ await _close_file_handle(
338
+ self._file_handle,
339
+ exclusive=exclusive,
340
+ )
341
+
342
+ return FileAccessContext(path=path)
343
+
344
+
345
+ class FileAccess(State):
346
+ """
347
+ State container for file access configuration within a context scope.
348
+
349
+ Provides the entry point for file operations within Haiway's context system.
350
+ Follows the framework's pattern of using state classes to configure behavior
351
+ that can be injected and replaced for testing.
352
+
353
+ File access is scoped to the context, meaning only one file can be open
354
+ at a time within a given context scope. This design ensures predictable
355
+ resource usage and simplifies error handling.
356
+
357
+ The default implementation provides standard filesystem access with
358
+ optional file creation and exclusive locking. Custom implementations
359
+ can be injected by replacing the accessing function.
360
+
361
+ Examples
362
+ --------
363
+ Basic file operations:
364
+
365
+ >>> async with ctx.scope("app", disposables=(FileAccess.open("config.json", create=True),)):
366
+ ... data = await File.read()
367
+ ... await File.write(b'{"setting": "value"}')
368
+
369
+ Exclusive file access for critical operations:
370
+
371
+ >>> async with ctx.scope("app", disposables=(FileAccess.open("config.json", exclusive=True),)):
372
+ ... content = await File.read()
373
+ ... processed = process_data(content)
374
+ ... await File.write(processed)
375
+ """
376
+
377
+ @classmethod
378
+ async def open(
379
+ cls,
380
+ path: Path | str,
381
+ create: bool = False,
382
+ exclusive: bool = False,
383
+ ) -> FileContext:
384
+ """
385
+ Open a file for reading and writing.
386
+
387
+ Opens a file using the configured file access implementation. The file
388
+ is opened with read and write permissions, and the entire file content
389
+ is available through the File.read() and File.write() methods.
390
+
391
+ Parameters
392
+ ----------
393
+ path : Path | str
394
+ The file path to open, as a Path object or string
395
+ create : bool, optional
396
+ If True, create the file and parent directories if they don't exist.
397
+ If False, raise FileException for missing files. Default is False
398
+ exclusive : bool, optional
399
+ If True, acquire an exclusive lock on the file for the duration of
400
+ the context. This prevents other processes from accessing the file
401
+ concurrently. Default is False
402
+
403
+ Returns
404
+ -------
405
+ FileContext
406
+ A FileContext that manages the file lifecycle and provides access
407
+ to file operations through the File state class
408
+
409
+ Raises
410
+ ------
411
+ FileException
412
+ If the file cannot be opened with the specified parameters, or if
413
+ a file is already open in the current context scope
414
+ """
415
+ return await ctx.state(cls).accessing(
416
+ path,
417
+ create=create,
418
+ exclusive=exclusive,
419
+ )
420
+
421
+ accessing: FileAccessing = _file_access_context
haiway/state/structure.py CHANGED
@@ -728,6 +728,24 @@ class State(metaclass=StateMeta):
728
728
  for key in self.__ATTRIBUTES__.keys()
729
729
  )
730
730
 
731
+ def __hash__(self) -> int:
732
+ hash_values: list[int] = []
733
+ for key in self.__ATTRIBUTES__.keys():
734
+ value: Any = getattr(self, key, MISSING)
735
+
736
+ # Skip MISSING values to ensure consistent hashing
737
+ if value is MISSING:
738
+ continue
739
+
740
+ # Convert to hashable representation
741
+ try:
742
+ hash_values.append(hash(value))
743
+
744
+ except TypeError:
745
+ continue # skip unhashable
746
+
747
+ return hash((self.__class__, tuple(hash_values)))
748
+
731
749
  def __setattr__(
732
750
  self,
733
751
  name: str,
haiway/types/missing.py CHANGED
@@ -47,6 +47,9 @@ class Missing(metaclass=MissingType):
47
47
  def __bool__(self) -> bool:
48
48
  return False
49
49
 
50
+ def __hash__(self) -> int:
51
+ return hash(self.__class__)
52
+
50
53
  def __eq__(
51
54
  self,
52
55
  value: object,
haiway/utils/__init__.py CHANGED
@@ -9,7 +9,6 @@ from haiway.utils.env import (
9
9
  load_env,
10
10
  )
11
11
  from haiway.utils.formatting import format_str
12
- from haiway.utils.freezing import freeze
13
12
  from haiway.utils.logs import setup_logging
14
13
  from haiway.utils.mimic import mimic_function
15
14
  from haiway.utils.noop import async_noop, noop
@@ -27,7 +26,6 @@ __all__ = (
27
26
  "async_always",
28
27
  "async_noop",
29
28
  "format_str",
30
- "freeze",
31
29
  "getenv_base64",
32
30
  "getenv_bool",
33
31
  "getenv_float",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: haiway
3
- Version: 0.21.4
3
+ Version: 0.22.1
4
4
  Summary: Framework for dependency injection and state management within structured concurrency model.
5
5
  Project-URL: Homepage, https://miquido.com
6
6
  Project-URL: Repository, https://github.com/miquido/haiway.git
@@ -40,7 +40,7 @@ Requires-Dist: pyright~=1.1; extra == 'dev'
40
40
  Requires-Dist: pytest-asyncio~=0.26; extra == 'dev'
41
41
  Requires-Dist: pytest-cov~=6.1; extra == 'dev'
42
42
  Requires-Dist: pytest~=8.3; extra == 'dev'
43
- Requires-Dist: ruff~=0.11; extra == 'dev'
43
+ Requires-Dist: ruff~=0.12; extra == 'dev'
44
44
  Provides-Extra: opentelemetry
45
45
  Requires-Dist: opentelemetry-api~=1.33; extra == 'opentelemetry'
46
46
  Requires-Dist: opentelemetry-exporter-otlp-proto-grpc~=1.33; extra == 'opentelemetry'
@@ -1,21 +1,22 @@
1
- haiway/__init__.py,sha256=keuz9FN8VqLamqrzvjK2IAjkdyyFcnboDrB9xkFPgXk,1861
1
+ haiway/__init__.py,sha256=FiOAMHHawyGk9FfZU-1UflT8nmwu9J0CrG2QwrJGccw,1917
2
2
  haiway/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  haiway/context/__init__.py,sha256=1N_SvdPkTfIZDZybm3y0rY2dGrDLWTm0ryzUz2XD4f8,1174
4
- haiway/context/access.py,sha256=60guObLq5lYjBxSnT9iaEZI5x35Xen_doxY4oCrbn9Q,21758
5
- haiway/context/disposables.py,sha256=0vf6kOZ80o6oa8IuU4xQttqtzMT4ODh33XuDh4SGOnc,4742
4
+ haiway/context/access.py,sha256=QCabyZtqqJjGTgAod1ZC3Fz3IfPsyurLo_i68RnCm4Q,25736
5
+ haiway/context/disposables.py,sha256=AP9eZ0BPHJZfjrrfrjSzr4jONMKkR6YmhjOfnBp37so,11504
6
6
  haiway/context/identifier.py,sha256=dCCwLneXJzH__ZWFlGRUHvoCmbT4lM0QVbyokYIbUHg,5255
7
7
  haiway/context/observability.py,sha256=gLKbMPNvt5ozrfyc4TGahN8A_dFFtyCjUIMZu9_wZHA,23722
8
- haiway/context/state.py,sha256=tRJRvd07XObhdayz-1OcNxABqcHQRD_k_yUGsn72wDU,9541
8
+ haiway/context/state.py,sha256=DuCtI0rMLWYbnBGXxpkbQgrX4aAftGkuK8XSd1JtdVc,11912
9
9
  haiway/context/tasks.py,sha256=pScFgeiyrXSJRDFZiYbBLi3k_DHkSlhB8rgAnYtgyrU,4925
10
10
  haiway/context/types.py,sha256=VDWXJySihfvSSPzY09PaGk6j5S9HgmAUboBGCZ8o_4k,766
11
- haiway/helpers/__init__.py,sha256=WzQFUHAX0NtpbdKycHywTyxfMGmid91y0vfmdIHX-NE,640
12
- haiway/helpers/asynchrony.py,sha256=kcGBTF7Dc2a0THH8usIq2OenspXx3KvuNrL8j7xyh80,5382
11
+ haiway/helpers/__init__.py,sha256=PTWpavAveEB2V9Au1QuaRZwh3Rkb1bQSNvo_mxuGqlE,721
12
+ haiway/helpers/asynchrony.py,sha256=Ddj8UdXhVczAbAC-rLpyhWa4RJ_W2Eolo45Veorq7_4,5362
13
13
  haiway/helpers/caching.py,sha256=BqgcUGQSAmXsuLi5V8EwlZzuGyutHOn1V4k7BHsGKeg,14347
14
14
  haiway/helpers/concurrent.py,sha256=xGMcan_tiETAHQs1YFmgYpA4YMFo6rIbFKvNeMlRFG8,2551
15
+ haiway/helpers/files.py,sha256=L6vXd8gdgWx5jPL8azloU8IGoFq2xnxjMc4ufz-gdl4,11650
15
16
  haiway/helpers/observability.py,sha256=3G0eRE1WYTGRujS0mxzYbLR4MlKnoYllE8cu2Eb_23w,11073
16
17
  haiway/helpers/retries.py,sha256=52LA85HejTiSmCmTMAA9c8oUqD_VnhbTn1b3kwlU52c,9032
17
18
  haiway/helpers/throttling.py,sha256=KBWUSHdKVMC5_nRMmmoPNwfp-3AcerQ6OczJa9gNLM0,5796
18
- haiway/helpers/timeouted.py,sha256=GQ8-btb36f0Jq7TnorAPYXyKScNmf0nxHXCYxqGl-o8,3949
19
+ haiway/helpers/timeouting.py,sha256=GQ8-btb36f0Jq7TnorAPYXyKScNmf0nxHXCYxqGl-o8,3949
19
20
  haiway/helpers/tracing.py,sha256=NHipA5UlngwFcAaKhXg1jTuJ-ti6AqSNxE7u7-92vWo,5409
20
21
  haiway/opentelemetry/__init__.py,sha256=TV-1C14mDAtcHhFZ29ActFQdrGH6x5KuGV9w-JlKYJg,91
21
22
  haiway/opentelemetry/observability.py,sha256=5fsHsFgjcxUcA0hIOM18lVvVdYSRO91ER52PticyzyU,25734
@@ -23,23 +24,22 @@ haiway/state/__init__.py,sha256=AaMqlMhO4zKS_XNevy3A7BHh5PxmguA-Sk_FnaNDY1Q,355
23
24
  haiway/state/attributes.py,sha256=sububiFP23aBB8RGk6OvTUp7BEY6S0kER_uHC09yins,26733
24
25
  haiway/state/path.py,sha256=bv5MI3HmUyku78k0Sz5lc7Q_Bay53iom1l3AL5KZs-4,32143
25
26
  haiway/state/requirement.py,sha256=zNTx7s8FiMZKu9EV3T6f1SOJpR4SC9X5hhL--PVWPCY,15641
26
- haiway/state/structure.py,sha256=KKIId-mrHAzGjYKKlvnlscMijVZVM8nDLnAwCFn1sTc,23259
27
+ haiway/state/structure.py,sha256=CTf1l0TyKA7vkVDqA9RMdxaOVNSHwQduN2jb6H015hg,23798
27
28
  haiway/state/validation.py,sha256=eDOZKRrfd-dmdbqoHcLacdCVKmVCEpwt239EG6ljNF8,23557
28
29
  haiway/types/__init__.py,sha256=jFr5kf36SvVGdgngvik6_HzG8YNa3NVsdDDSqxVuGm4,281
29
30
  haiway/types/default.py,sha256=59chcOaoGqI2to08RamCCLluimfYbJp5xbYl3fWaLrM,4153
30
- haiway/types/missing.py,sha256=bVlOJN0Z04gALKj01Ah6_sWbC94mwRatdEcW7neLAsY,4188
31
- haiway/utils/__init__.py,sha256=HOylRgBEa0uNxEuPBupaJ28l4wEQiy98cGJi2Gtirr4,972
31
+ haiway/types/missing.py,sha256=OfiyYUnzTk3arKWu8S6ORCEYGvcRu_mdL4j1ExdSvgI,4256
32
+ haiway/utils/__init__.py,sha256=Zs4mJnoRL_4ssGSZqvCFuhllxMDww_8-McsI2xB0mug,917
32
33
  haiway/utils/always.py,sha256=dd6jDQ1j4DpJjTKO1J2Tv5xS8X1LnMC4kQ0D7DtKUvw,1230
33
34
  haiway/utils/collections.py,sha256=gF5tC1EaEzBfPpXrHqR0mZh8e4pRwEPSVactvfN-30M,4737
34
35
  haiway/utils/env.py,sha256=Z0uHJDFegvgzy-gM-f0uPMha9_1ldUglrD5SKNJsvYE,9445
35
36
  haiway/utils/formatting.py,sha256=jgSIGalGUBZVo2ziiNC5Y7vBYbAEwPugOiwEOrNFTcI,4039
36
- haiway/utils/freezing.py,sha256=HJH0SOgPCreb9o0wPeaMPMxhS9JDuzzey6UsKhuvUJU,1292
37
37
  haiway/utils/logs.py,sha256=NuwoqKQnMNi1FMIA91cVFnAPefUFeg3UIT50IOl3sJk,1571
38
38
  haiway/utils/mimic.py,sha256=xaZiUKp096QFfdSw7cNIKEWt2UIS7vf880KF54gny38,1831
39
39
  haiway/utils/noop.py,sha256=U8ocfoCgt-pY0owJDPtrRrj53cabeIXH9qCKWMQnoRk,1336
40
40
  haiway/utils/queue.py,sha256=6v2u3pA6A44IuCCTOjmCt3yLyOcm7PCRnrIGo25j-1o,6402
41
41
  haiway/utils/stream.py,sha256=lXaeveTY0-AYG5xVzcQYaiC6SUD5fUtHoMXiQcrQAAM,5723
42
- haiway-0.21.4.dist-info/METADATA,sha256=nxks1ZdXY-7iFAZLDZub4tH8eSgJeuF75wMtWL0rTYc,4919
43
- haiway-0.21.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
44
- haiway-0.21.4.dist-info/licenses/LICENSE,sha256=3phcpHVNBP8jsi77gOO0E7rgKeDeu99Pi7DSnK9YHoQ,1069
45
- haiway-0.21.4.dist-info/RECORD,,
42
+ haiway-0.22.1.dist-info/METADATA,sha256=5M6G9KRMDiidXN6qWibXcpns40x11JTHCHNx5WOdD2U,4919
43
+ haiway-0.22.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
44
+ haiway-0.22.1.dist-info/licenses/LICENSE,sha256=3phcpHVNBP8jsi77gOO0E7rgKeDeu99Pi7DSnK9YHoQ,1069
45
+ haiway-0.22.1.dist-info/RECORD,,
haiway/utils/freezing.py DELETED
@@ -1,46 +0,0 @@
1
- from typing import Any
2
-
3
- __all__ = ("freeze",)
4
-
5
-
6
- def freeze(
7
- instance: object,
8
- /,
9
- ) -> None:
10
- """
11
- Make an object instance immutable by preventing attribute modification.
12
-
13
- This function modifies the given object to prevent further changes to its attributes.
14
- It replaces the object's __setattr__ and __delattr__ methods with ones that raise
15
- exceptions, effectively making the object immutable after this function is called.
16
-
17
- Parameters
18
- ----------
19
- instance : object
20
- The object instance to make immutable
21
-
22
- Returns
23
- -------
24
- None
25
- The object is modified in-place
26
-
27
- Notes
28
- -----
29
- - This only affects direct attribute assignments and deletions
30
- - Mutable objects contained within the instance can still be modified internally
31
- - The object's class remains unchanged, only the specific instance is affected
32
- """
33
-
34
- def frozen_set(
35
- __name: str,
36
- __value: Any,
37
- ) -> None:
38
- raise RuntimeError(f"{instance.__class__.__qualname__} is frozen and can't be modified")
39
-
40
- def frozen_del(
41
- __name: str,
42
- ) -> None:
43
- raise RuntimeError(f"{instance.__class__.__qualname__} is frozen and can't be modified")
44
-
45
- instance.__delattr__ = frozen_del
46
- instance.__setattr__ = frozen_set
File without changes