haiway 0.21.4__py3-none-any.whl → 0.22.0__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,29 @@ 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
+ ) -> bool:
543
+ """
544
+ Check if state object is available in the current context.
545
+
546
+ Verifies if state object of the specified type is available the current context.
547
+ Instantiates requested state if needed and possible.
548
+
549
+ Parameters
550
+ ----------
551
+ state: type[StateType]
552
+ The type of state to check
553
+
554
+ Returns
555
+ -------
556
+ bool
557
+ True if state is available, otherwise False.
558
+ """
559
+ return StateContext.check_state(state)
560
+
496
561
  @staticmethod
497
562
  def state[StateType: State](
498
563
  state: type[StateType],
@@ -500,18 +565,60 @@ class ctx:
500
565
  default: StateType | None = None,
501
566
  ) -> StateType:
502
567
  """
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.
568
+ Access state from the current scope context by its type.
569
+
570
+ Retrieves state objects that have been propagated within the current execution context.
571
+ State objects are automatically made available through context scopes and disposables.
572
+ If no matching state is found, creates a default instance if possible.
505
573
 
506
574
  Parameters
507
575
  ----------
508
576
  state: type[StateType]
509
- type of requested state
577
+ The State class type to retrieve from the current context
578
+ default: StateType | None, default=None
579
+ Optional default instance to return if state is not found in context.
580
+ If None and no state is found, a new instance will be created if possible.
510
581
 
511
582
  Returns
512
583
  -------
513
584
  StateType
514
- resolved state instance
585
+ The state instance from the current context or a default/new instance
586
+
587
+ Raises
588
+ ------
589
+ RuntimeError
590
+ If called outside of any scope context
591
+ TypeError
592
+ If no state is found and no default can be created
593
+
594
+ Examples
595
+ --------
596
+ Accessing configuration state:
597
+
598
+ >>> from haiway import ctx, State
599
+ >>>
600
+ >>> class ApiConfig(State):
601
+ ... base_url: str = "https://api.example.com"
602
+ ... timeout: int = 30
603
+ >>>
604
+ >>> async def fetch_data():
605
+ ... config = ctx.state(ApiConfig)
606
+ ... # Use config.base_url and config.timeout
607
+ >>>
608
+ >>> async with ctx.scope("api", ApiConfig(base_url="https://custom.api.com")):
609
+ ... await fetch_data() # Uses custom config
610
+
611
+ Accessing state with default:
612
+
613
+ >>> cache_config = ctx.state(CacheConfig, default=CacheConfig(ttl=3600))
614
+
615
+ Within service classes:
616
+
617
+ >>> class UserService(State):
618
+ ... @classmethod
619
+ ... async def get_user(cls, user_id: str) -> User:
620
+ ... config = ctx.state(DatabaseConfig)
621
+ ... # Use config to connect to database
515
622
  """
516
623
  return StateContext.state(
517
624
  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 ScopeState, 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,25 +194,106 @@ 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]:
198
+ assert self._loop is None # nosec: B101
199
+ object.__setattr__(
200
+ self,
201
+ "_loop",
202
+ get_running_loop(),
203
+ )
204
+ return [
205
+ *chain.from_iterable(
206
+ await gather(
207
+ *[self._setup(disposable) for disposable in self._disposables],
208
+ )
209
+ )
210
+ ]
211
+
212
+ async def __aenter__(self) -> None:
104
213
  """
105
214
  Enter all contained disposables asynchronously.
106
215
 
107
- 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.
216
+ Enters all disposables in parallel and collects any State objects they return updating
217
+ current state context.
113
218
  """
114
- return [
115
- *chain.from_iterable(
116
- state
117
- for state in await gather(
118
- *[self._initialize(disposable) for disposable in self._disposables],
219
+
220
+ assert self._state_context is None, "Context reentrance is not allowed" # nosec: B101
221
+ state_context = StateContext(state=ScopeState(await self.prepare()))
222
+ state_context.__enter__()
223
+ object.__setattr__(
224
+ self,
225
+ "_state_context",
226
+ state_context,
227
+ )
228
+
229
+ async def _cleanup(
230
+ self,
231
+ /,
232
+ exc_type: type[BaseException] | None,
233
+ exc_val: BaseException | None,
234
+ exc_tb: TracebackType | None,
235
+ ) -> list[bool | BaseException | None]:
236
+ return await gather(
237
+ *[
238
+ disposable.__aexit__(
239
+ exc_type,
240
+ exc_val,
241
+ exc_tb,
119
242
  )
243
+ for disposable in self._disposables
244
+ ],
245
+ return_exceptions=True,
246
+ )
247
+
248
+ async def dispose(
249
+ self,
250
+ /,
251
+ exc_type: type[BaseException] | None = None,
252
+ exc_val: BaseException | None = None,
253
+ exc_tb: TracebackType | None = None,
254
+ ) -> None:
255
+ assert self._loop is not None # nosec: B101
256
+ results: list[bool | BaseException | None]
257
+
258
+ try:
259
+ current_loop: AbstractEventLoop = get_running_loop()
260
+ if self._loop != current_loop:
261
+ results = await wrap_future(
262
+ run_coroutine_threadsafe(
263
+ self._cleanup(
264
+ exc_type,
265
+ exc_val,
266
+ exc_tb,
267
+ ),
268
+ loop=self._loop,
269
+ )
270
+ )
271
+
272
+ else:
273
+ results = await self._cleanup(
274
+ exc_type,
275
+ exc_val,
276
+ exc_tb,
277
+ )
278
+
279
+ finally:
280
+ object.__setattr__(
281
+ self,
282
+ "_loop",
283
+ None,
120
284
  )
121
- ]
285
+
286
+ exceptions: list[BaseException] = [exc for exc in results if isinstance(exc, BaseException)]
287
+
288
+ match len(exceptions):
289
+ case 0:
290
+ return
291
+
292
+ case 1:
293
+ raise exceptions[0]
294
+
295
+ case _:
296
+ raise BaseExceptionGroup("Disposables cleanup errors", exceptions)
122
297
 
123
298
  async def __aexit__(
124
299
  self,
@@ -146,19 +321,37 @@ class Disposables:
146
321
  BaseExceptionGroup
147
322
  If multiple disposables raise exceptions during exit
148
323
  """
149
- results: list[bool | BaseException | None] = await gather(
150
- *[
151
- disposable.__aexit__(
152
- exc_type,
153
- exc_val,
154
- exc_tb,
155
- )
156
- for disposable in self._disposables
157
- ],
158
- return_exceptions=True,
159
- )
324
+ assert self._state_context is not None, "Unbalanced context enter/exit" # nosec: B101
325
+ try:
326
+ self._state_context.__exit__(
327
+ exc_type,
328
+ exc_val,
329
+ exc_tb,
330
+ )
331
+ object.__setattr__(
332
+ self,
333
+ "_state_context",
334
+ None,
335
+ )
160
336
 
161
- exceptions: list[BaseException] = [exc for exc in results if isinstance(exc, BaseException)]
337
+ finally:
338
+ await self.dispose(
339
+ exc_type,
340
+ exc_val,
341
+ exc_tb,
342
+ )
343
+
344
+ def __call__[Result, **Arguments](
345
+ self,
346
+ function: Callable[Arguments, Coroutine[Any, Any, Result]],
347
+ ) -> Callable[Arguments, Coroutine[Any, Any, Result]]:
348
+ assert iscoroutinefunction(function) # nosec: B101
349
+
350
+ async def async_context(
351
+ *args: Arguments.args,
352
+ **kwargs: Arguments.kwargs,
353
+ ) -> Result:
354
+ async with self:
355
+ return await function(*args, **kwargs)
162
356
 
163
- if len(exceptions) > 1:
164
- raise BaseExceptionGroup("Disposing errors", exceptions)
357
+ 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,43 @@ 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
+ ) -> bool:
71
+ """
72
+ Check state object availability by its type.
73
+
74
+ If the state type is not found, attempts to instantiate a new instance of\
75
+ the type if possible.
76
+
77
+ Parameters
78
+ ----------
79
+ state: type[StateType]
80
+ The type of state to check
81
+
82
+ Returns
83
+ -------
84
+ bool
85
+ True if state is available, otherwise False.
86
+ """
87
+ if state in self._state:
88
+ return True
89
+
90
+ else:
91
+ with self._lock:
92
+ if state in self._state:
93
+ return True
94
+
95
+ try:
96
+ initialized: StateType = state()
97
+ self._state[state] = initialized
98
+ return True
99
+
100
+ except BaseException:
101
+ return False # unavailable, we don't care the exception
102
+
59
103
  def state[StateType: State](
60
104
  self,
61
105
  state: type[StateType],
@@ -93,17 +137,20 @@ class ScopeState:
93
137
  return default
94
138
 
95
139
  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
140
+ with self._lock:
141
+ if state in self._state:
142
+ return cast(StateType, self._state[state])
143
+
144
+ try:
145
+ initialized: StateType = state()
146
+ self._state[state] = initialized
147
+ return initialized
148
+
149
+ except Exception as exc:
150
+ raise MissingState(
151
+ f"{state.__qualname__} is not defined in current scope"
152
+ " and failed to provide a default value"
153
+ ) from exc
107
154
 
108
155
  def updated(
109
156
  self,
@@ -149,6 +196,33 @@ class StateContext:
149
196
 
150
197
  _context = ContextVar[ScopeState]("StateContext")
151
198
 
199
+ @classmethod
200
+ def check_state[StateType: State](
201
+ cls,
202
+ state: type[StateType],
203
+ /,
204
+ ) -> bool:
205
+ """
206
+ Check if state object is available in the current context.
207
+
208
+ Verifies if state object of the specified type is available the current context.
209
+
210
+ Parameters
211
+ ----------
212
+ state: type[StateType]
213
+ The type of state to check
214
+
215
+ Returns
216
+ -------
217
+ bool
218
+ True if state is available, otherwise False.
219
+ """
220
+ try:
221
+ return cls._context.get().check_state(state)
222
+
223
+ except LookupError:
224
+ return False # no context no state
225
+
152
226
  @classmethod
153
227
  def state[StateType: State](
154
228
  cls,
@@ -1,6 +1,7 @@
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
@@ -11,6 +12,8 @@ __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,14 @@ class State(metaclass=StateMeta):
728
728
  for key in self.__ATTRIBUTES__.keys()
729
729
  )
730
730
 
731
+ def __hash__(self) -> int:
732
+ return hash(
733
+ (
734
+ self.__class__,
735
+ *tuple(getattr(self, key, MISSING) for key in sorted(self.__ATTRIBUTES__.keys())),
736
+ )
737
+ )
738
+
731
739
  def __setattr__(
732
740
  self,
733
741
  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,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: haiway
3
- Version: 0.21.4
3
+ Version: 0.22.0
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,17 +1,18 @@
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=j5wC0kluFEb1NpTsUG7fgFVklfeNCqb0dygK1VIdLuo,25485
5
+ haiway/context/disposables.py,sha256=yRABVbUJIMP6r5oqO-Yl4y20ulhlDuQ2lLnI25QCltg,10495
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=Ni6NUbRYKmgi7HhvosrVH62IzfalLDUH7VYRwpUiQ78,11426
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=uEJATJOiqRWeOPMjrGdn_CmWvwrlUQ1gmrau4XsBgUk,720
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
@@ -23,11 +24,11 @@ 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=XzPDCrFUSy-PKVzI9G6TqechRW3nXSbTK7f_AhxCzq4,23481
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/types/missing.py,sha256=OfiyYUnzTk3arKWu8S6ORCEYGvcRu_mdL4j1ExdSvgI,4256
31
32
  haiway/utils/__init__.py,sha256=HOylRgBEa0uNxEuPBupaJ28l4wEQiy98cGJi2Gtirr4,972
32
33
  haiway/utils/always.py,sha256=dd6jDQ1j4DpJjTKO1J2Tv5xS8X1LnMC4kQ0D7DtKUvw,1230
33
34
  haiway/utils/collections.py,sha256=gF5tC1EaEzBfPpXrHqR0mZh8e4pRwEPSVactvfN-30M,4737
@@ -39,7 +40,7 @@ haiway/utils/mimic.py,sha256=xaZiUKp096QFfdSw7cNIKEWt2UIS7vf880KF54gny38,1831
39
40
  haiway/utils/noop.py,sha256=U8ocfoCgt-pY0owJDPtrRrj53cabeIXH9qCKWMQnoRk,1336
40
41
  haiway/utils/queue.py,sha256=6v2u3pA6A44IuCCTOjmCt3yLyOcm7PCRnrIGo25j-1o,6402
41
42
  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,,
43
+ haiway-0.22.0.dist-info/METADATA,sha256=6__3ZCA0Klv8j7coosQhvQY9GWNqyVlUKSIRbh1f7Jw,4919
44
+ haiway-0.22.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
45
+ haiway-0.22.0.dist-info/licenses/LICENSE,sha256=3phcpHVNBP8jsi77gOO0E7rgKeDeu99Pi7DSnK9YHoQ,1069
46
+ haiway-0.22.0.dist-info/RECORD,,