coopy 0.6.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.
coopy/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ __version__ = "0.6.0"
2
+
3
+ from .asyncio import AsyncPersistent as AsyncPersistent
4
+ from .base import control as control
5
+ from .base import init_persistent_system as init_persistent_system
6
+ from .base import init_persistent_system as open
7
+ from .base import logging_config as logging_config
8
+ from .error import CoopyError as CoopyError
9
+ from .serialization import ActionSchemas as ActionSchemas
10
+ from .serialization import JsonSerializer as JsonSerializer
11
+ from .serialization import PickleSerializer as PickleSerializer
12
+ from .serialization import SerializationError as SerializationError
13
+ from .serialization import Serializer as Serializer
14
+ from .serialization import TypeRegistry as TypeRegistry
15
+
16
+ __all__ = [
17
+ "open",
18
+ "init_persistent_system",
19
+ "control",
20
+ "logging_config",
21
+ "CoopyError",
22
+ "Serializer",
23
+ "PickleSerializer",
24
+ "JsonSerializer",
25
+ "TypeRegistry",
26
+ "ActionSchemas",
27
+ "SerializationError",
28
+ "AsyncPersistent",
29
+ ]
coopy/asyncio.py ADDED
@@ -0,0 +1,175 @@
1
+ """Async callers for synchronous persistent domains, with one writer thread."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import logging
8
+ from concurrent.futures import Future, ThreadPoolExecutor
9
+ from types import TracebackType
10
+ from typing import Any, Callable, Coroutine, cast
11
+
12
+ from .base import CoopyProxy, PathLike, init_persistent_system
13
+ from .error import ConfigurationError, LifecycleError
14
+
15
+ logger = logging.getLogger("coopy")
16
+
17
+
18
+ def _observe(future: asyncio.Future[Any]) -> None:
19
+ # Shielded work may outlive a cancelled waiter. Retrieve its exception to
20
+ # avoid an unhandled-future warning; other waiters still receive it normally.
21
+ if not future.cancelled():
22
+ future.exception()
23
+
24
+
25
+ class AsyncPersistent:
26
+ """Use ``async with AsyncPersistent(Domain, path) as domain``.
27
+
28
+ Opening, domain methods, snapshots and close run on one dedicated thread.
29
+ Each instance belongs to one event loop. Domain methods remain synchronous.
30
+ """
31
+
32
+ def __init__(self, obj: Any, basedir: PathLike | None = None, **options: Any) -> None:
33
+ if options.get("recovery", "strict") != "strict":
34
+ raise ConfigurationError("AsyncPersistent requires strict recovery")
35
+ self._obj = obj
36
+ self._basedir = basedir
37
+ self._options = options
38
+ self._state = "new"
39
+ self._loop: asyncio.AbstractEventLoop | None = None
40
+ self._executor: ThreadPoolExecutor | None = None
41
+ self._opening: Future[CoopyProxy] | None = None
42
+ self._close_task: asyncio.Task[None] | None = None
43
+
44
+ def _check_loop(self) -> None:
45
+ loop = asyncio.get_running_loop()
46
+ if self._loop is not None and self._loop is not loop:
47
+ raise LifecycleError("AsyncPersistent belongs to another event loop")
48
+
49
+ @classmethod
50
+ async def open(
51
+ cls, obj: Any, basedir: PathLike | None = None, **options: Any
52
+ ) -> AsyncPersistent:
53
+ """Open without a context; the caller must await ``aclose()``."""
54
+ instance = cls(obj, basedir, **options)
55
+ return await instance.__aenter__()
56
+
57
+ async def __aenter__(self) -> AsyncPersistent:
58
+ self._check_loop()
59
+ if self._state != "new":
60
+ raise LifecycleError("AsyncPersistent can only be opened once")
61
+ self._loop = asyncio.get_running_loop()
62
+ self._state = "opening"
63
+ self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="coopy-writer")
64
+ self._opening = self._executor.submit(
65
+ lambda: cast(
66
+ CoopyProxy, init_persistent_system(self._obj, self._basedir, **self._options)
67
+ )
68
+ )
69
+ future = asyncio.wrap_future(self._opening)
70
+ future.add_done_callback(_observe)
71
+ try:
72
+ await asyncio.shield(future)
73
+ if self._state != "opening":
74
+ raise LifecycleError("AsyncPersistent closed during opening")
75
+ self._state = "open"
76
+ return self
77
+ except BaseException:
78
+ try:
79
+ await self.aclose()
80
+ except Exception:
81
+ logger.exception("Cleanup failed while opening async persistence")
82
+ raise
83
+
84
+ async def __aexit__(
85
+ self,
86
+ exc_type: type[BaseException] | None,
87
+ exc: BaseException | None,
88
+ traceback: TracebackType | None,
89
+ ) -> None:
90
+ try:
91
+ await self.aclose()
92
+ except Exception:
93
+ if exc is None:
94
+ raise
95
+ logger.exception("Cleanup failed while propagating async context exception")
96
+
97
+ async def _dispatch(self, operation: Callable[[CoopyProxy], Any]) -> Any:
98
+ self._check_loop()
99
+ if self._state != "open":
100
+ raise LifecycleError("AsyncPersistent is not open")
101
+ assert self._executor is not None and self._opening is not None
102
+ opening = self._opening
103
+ # No await between checking state and submission: close cannot overtake
104
+ # an accepted operation on the owning event loop.
105
+ future = asyncio.wrap_future(self._executor.submit(lambda: operation(opening.result())))
106
+ future.add_done_callback(_observe)
107
+ return await asyncio.shield(future)
108
+
109
+ async def call(self, name: str, *args: Any, **kwargs: Any) -> Any:
110
+ """Call a domain method, including names reserved by this facade."""
111
+ if name.startswith("_"):
112
+ raise AttributeError("AsyncPersistent only exposes public domain methods")
113
+
114
+ def invoke(proxy: CoopyProxy) -> Any:
115
+ # Lookup occurs on the writer too; descriptors must not run on the
116
+ # event loop. Never fall through to synchronous proxy controls.
117
+ method = getattr(proxy.obj, name)
118
+ if not callable(method):
119
+ raise TypeError("Use a @readonly domain method to read attributes")
120
+ if inspect.iscoroutinefunction(method) or inspect.isasyncgenfunction(method):
121
+ raise TypeError("Persistent domain methods must be synchronous")
122
+ return proxy.__getattr__(name)(*args, **kwargs)
123
+
124
+ return await self._dispatch(invoke)
125
+
126
+ def __getattr__(self, name: str) -> Callable[..., Coroutine[Any, Any, Any]]:
127
+ if name.startswith("_"):
128
+ raise AttributeError(name)
129
+
130
+ async def method(*args: Any, **kwargs: Any) -> Any:
131
+ return await self.call(name, *args, **kwargs)
132
+
133
+ return method
134
+
135
+ async def take_snapshot(self) -> None:
136
+ """Queue a blocking checkpoint on the writer thread."""
137
+ await self._dispatch(lambda proxy: CoopyProxy.take_snapshot(proxy))
138
+
139
+ async def aclose(self) -> None:
140
+ """Reject new work, drain accepted calls, close storage and join writer.
141
+
142
+ Cancelling this await does not cancel cleanup. Await it again to observe
143
+ completion or its error before shutting down the event loop.
144
+ """
145
+ self._check_loop()
146
+ if self._close_task is None:
147
+ if self._state == "new":
148
+ self._state = "closed"
149
+ return
150
+ if self._state == "closed":
151
+ return
152
+ self._state = "closing"
153
+ self._close_task = asyncio.create_task(self._finish_close())
154
+ self._close_task.add_done_callback(_observe)
155
+ await asyncio.shield(self._close_task)
156
+
157
+ async def _finish_close(self) -> None:
158
+ assert self._executor is not None and self._opening is not None
159
+ executor, opening = self._executor, self._opening
160
+
161
+ def close() -> None:
162
+ try:
163
+ proxy = opening.result()
164
+ except BaseException:
165
+ return # Opening's caller receives the original failure.
166
+ CoopyProxy.close(proxy)
167
+
168
+ try:
169
+ await asyncio.wrap_future(executor.submit(close))
170
+ finally:
171
+ try:
172
+ # Joining a worker must not block the event loop either.
173
+ await asyncio.to_thread(executor.shutdown, wait=True)
174
+ finally:
175
+ self._state = "closed"