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 +29 -0
- coopy/asyncio.py +175 -0
- coopy/base.py +745 -0
- coopy/compression.py +43 -0
- coopy/decorators.py +25 -0
- coopy/error.py +37 -0
- coopy/fileutils.py +210 -0
- coopy/foundation.py +108 -0
- coopy/integrity.py +110 -0
- coopy/journal.py +175 -0
- coopy/locking.py +115 -0
- coopy/manifest.py +107 -0
- coopy/network/__init__.py +1 -0
- coopy/network/default_select.py +351 -0
- coopy/network/network.py +52 -0
- coopy/py.typed +0 -0
- coopy/restore.py +241 -0
- coopy/serialization.py +299 -0
- coopy/snapshot.py +256 -0
- coopy/tests/__init__.py +0 -0
- coopy/tests/utils.py +25 -0
- coopy/utils.py +17 -0
- coopy/validation.py +104 -0
- coopy-0.6.0.dist-info/METADATA +337 -0
- coopy-0.6.0.dist-info/RECORD +28 -0
- coopy-0.6.0.dist-info/WHEEL +5 -0
- coopy-0.6.0.dist-info/licenses/LICENSE +27 -0
- coopy-0.6.0.dist-info/top_level.txt +1 -0
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"
|