polystore 0.1.2__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.
- polystore/__init__.py +80 -0
- polystore/atomic.py +249 -0
- polystore/backend_registry.py +179 -0
- polystore/base.py +318 -0
- polystore/disk.py +783 -0
- polystore/exceptions.py +55 -0
- polystore/filemanager.py +770 -0
- polystore/formats.py +79 -0
- polystore/lazy_imports.py +127 -0
- polystore/memory.py +654 -0
- polystore/registry.py +9 -0
- polystore/streaming.py +241 -0
- polystore/utils.py +42 -0
- polystore/zarr.py +1266 -0
- polystore-0.1.2.dist-info/METADATA +249 -0
- polystore-0.1.2.dist-info/RECORD +19 -0
- polystore-0.1.2.dist-info/WHEEL +5 -0
- polystore-0.1.2.dist-info/licenses/LICENSE +22 -0
- polystore-0.1.2.dist-info/top_level.txt +1 -0
polystore/__init__.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Polystore - Framework-agnostic multi-backend storage abstraction.
|
|
3
|
+
|
|
4
|
+
Provides pluggable storage backends with multi-framework I/O support for
|
|
5
|
+
NumPy, PyTorch, JAX, TensorFlow, CuPy, and Zarr.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.2"
|
|
9
|
+
|
|
10
|
+
# Core abstractions
|
|
11
|
+
from .base import (
|
|
12
|
+
DataSink,
|
|
13
|
+
DataSource,
|
|
14
|
+
StorageBackend,
|
|
15
|
+
VirtualBackend,
|
|
16
|
+
ReadOnlyBackend,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Concrete backends
|
|
20
|
+
from .memory import MemoryBackend
|
|
21
|
+
from .disk import DiskBackend
|
|
22
|
+
|
|
23
|
+
# Optional backends
|
|
24
|
+
try:
|
|
25
|
+
from .zarr import ZarrBackend
|
|
26
|
+
except ImportError:
|
|
27
|
+
ZarrBackend = None
|
|
28
|
+
|
|
29
|
+
# File manager
|
|
30
|
+
from .filemanager import FileManager
|
|
31
|
+
|
|
32
|
+
# Registry
|
|
33
|
+
from .backend_registry import BackendRegistry, create_storage_registry
|
|
34
|
+
|
|
35
|
+
# Atomic operations
|
|
36
|
+
from .atomic import atomic_write, atomic_write_json
|
|
37
|
+
|
|
38
|
+
# Exceptions
|
|
39
|
+
from .exceptions import (
|
|
40
|
+
StorageError,
|
|
41
|
+
StorageResolutionError,
|
|
42
|
+
BackendNotFoundError,
|
|
43
|
+
UnsupportedFormatError,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Streaming (optional)
|
|
47
|
+
try:
|
|
48
|
+
from .streaming import StreamingBackend
|
|
49
|
+
except ImportError:
|
|
50
|
+
StreamingBackend = None
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
# Version
|
|
54
|
+
"__version__",
|
|
55
|
+
# Core abstractions
|
|
56
|
+
"DataSink",
|
|
57
|
+
"DataSource",
|
|
58
|
+
"StorageBackend",
|
|
59
|
+
"VirtualBackend",
|
|
60
|
+
"ReadOnlyBackend",
|
|
61
|
+
# Backends
|
|
62
|
+
"MemoryBackend",
|
|
63
|
+
"DiskBackend",
|
|
64
|
+
"ZarrBackend",
|
|
65
|
+
# File manager
|
|
66
|
+
"FileManager",
|
|
67
|
+
# Registry
|
|
68
|
+
"BackendRegistry",
|
|
69
|
+
# Atomic operations
|
|
70
|
+
"atomic_write",
|
|
71
|
+
"atomic_write_json",
|
|
72
|
+
# Exceptions
|
|
73
|
+
"StorageError",
|
|
74
|
+
"StorageResolutionError",
|
|
75
|
+
"BackendNotFoundError",
|
|
76
|
+
"UnsupportedFormatError",
|
|
77
|
+
# Streaming
|
|
78
|
+
"StreamingBackend",
|
|
79
|
+
]
|
|
80
|
+
|
polystore/atomic.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Atomic file operations with locking for OpenHCS.
|
|
3
|
+
|
|
4
|
+
Provides utilities for atomic read-modify-write operations with file locking
|
|
5
|
+
to prevent concurrency issues in multiprocessing environments.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Callable, Dict, Optional, TypeVar, Union
|
|
17
|
+
|
|
18
|
+
# Cross-platform file locking
|
|
19
|
+
try:
|
|
20
|
+
import fcntl
|
|
21
|
+
FCNTL_AVAILABLE = True
|
|
22
|
+
except ImportError:
|
|
23
|
+
# Windows compatibility - use portalocker
|
|
24
|
+
import portalocker
|
|
25
|
+
FCNTL_AVAILABLE = False
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
T = TypeVar('T')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class LockConfig:
|
|
34
|
+
"""Configuration constants for file locking operations."""
|
|
35
|
+
DEFAULT_TIMEOUT: float = 30.0
|
|
36
|
+
DEFAULT_POLL_INTERVAL: float = 0.1
|
|
37
|
+
LOCK_SUFFIX: str = '.lock'
|
|
38
|
+
TEMP_PREFIX: str = '.tmp'
|
|
39
|
+
JSON_INDENT: int = 2
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
LOCK_CONFIG = LockConfig()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FileLockError(Exception):
|
|
46
|
+
"""Raised when file locking operations fail."""
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FileLockTimeoutError(FileLockError):
|
|
51
|
+
"""Raised when file lock acquisition times out."""
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@contextmanager
|
|
56
|
+
def file_lock(
|
|
57
|
+
lock_path: Union[str, Path],
|
|
58
|
+
timeout: float = LOCK_CONFIG.DEFAULT_TIMEOUT,
|
|
59
|
+
poll_interval: float = LOCK_CONFIG.DEFAULT_POLL_INTERVAL
|
|
60
|
+
):
|
|
61
|
+
"""Context manager for exclusive file locking."""
|
|
62
|
+
lock_path = Path(lock_path)
|
|
63
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
|
|
65
|
+
lock_fd = None
|
|
66
|
+
try:
|
|
67
|
+
lock_fd = _acquire_lock_with_timeout(lock_path, timeout, poll_interval)
|
|
68
|
+
yield
|
|
69
|
+
except FileLockTimeoutError:
|
|
70
|
+
raise
|
|
71
|
+
except Exception as e:
|
|
72
|
+
raise FileLockError(f"File lock operation failed for {lock_path}: {e}") from e
|
|
73
|
+
finally:
|
|
74
|
+
_cleanup_lock(lock_fd, lock_path)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _acquire_lock_with_timeout(lock_path: Path, timeout: float, poll_interval: float) -> int:
|
|
78
|
+
"""Acquire file lock with timeout and return file descriptor."""
|
|
79
|
+
deadline = time.time() + timeout
|
|
80
|
+
|
|
81
|
+
while time.time() < deadline:
|
|
82
|
+
if lock_fd := _try_acquire_lock(lock_path):
|
|
83
|
+
return lock_fd
|
|
84
|
+
time.sleep(poll_interval)
|
|
85
|
+
|
|
86
|
+
raise FileLockTimeoutError(f"Failed to acquire lock {lock_path} within {timeout}s")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _try_acquire_lock(lock_path: Path) -> Optional[int]:
|
|
90
|
+
"""Try to acquire lock once, return fd or None."""
|
|
91
|
+
try:
|
|
92
|
+
lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_WRONLY | os.O_TRUNC)
|
|
93
|
+
if FCNTL_AVAILABLE:
|
|
94
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
95
|
+
else:
|
|
96
|
+
# Windows: use portalocker
|
|
97
|
+
portalocker.lock(lock_fd, portalocker.LOCK_EX | portalocker.LOCK_NB)
|
|
98
|
+
logger.debug(f"Acquired file lock: {lock_path}")
|
|
99
|
+
return lock_fd
|
|
100
|
+
except (OSError, IOError):
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _cleanup_lock(lock_fd: Optional[int], lock_path: Path) -> None:
|
|
105
|
+
"""Clean up file lock resources."""
|
|
106
|
+
if lock_fd is not None:
|
|
107
|
+
try:
|
|
108
|
+
if FCNTL_AVAILABLE:
|
|
109
|
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
110
|
+
else:
|
|
111
|
+
# Windows: use portalocker
|
|
112
|
+
portalocker.unlock(lock_fd)
|
|
113
|
+
os.close(lock_fd)
|
|
114
|
+
logger.debug(f"Released file lock: {lock_path}")
|
|
115
|
+
except Exception as e:
|
|
116
|
+
logger.warning(f"Error releasing lock {lock_path}: {e}")
|
|
117
|
+
|
|
118
|
+
if lock_path.exists():
|
|
119
|
+
try:
|
|
120
|
+
lock_path.unlink()
|
|
121
|
+
except Exception as e:
|
|
122
|
+
logger.warning(f"Error removing lock file {lock_path}: {e}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@contextmanager
|
|
126
|
+
def atomic_write(file_path: Union[str, Path], mode: str = 'w', ensure_directory: bool = True):
|
|
127
|
+
"""
|
|
128
|
+
Context manager for atomic file writes.
|
|
129
|
+
|
|
130
|
+
Writes to a temporary file first, then renames to the target path.
|
|
131
|
+
This ensures the operation is atomic - either fully succeeds or fails
|
|
132
|
+
without leaving partial writes.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
file_path: Target file path
|
|
136
|
+
mode: File mode ('w' for text, 'wb' for binary)
|
|
137
|
+
ensure_directory: Create parent directory if it doesn't exist
|
|
138
|
+
|
|
139
|
+
Example:
|
|
140
|
+
with atomic_write("output.txt") as f:
|
|
141
|
+
f.write("data")
|
|
142
|
+
"""
|
|
143
|
+
file_path = Path(file_path)
|
|
144
|
+
|
|
145
|
+
if ensure_directory:
|
|
146
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
|
|
148
|
+
# Create temporary file in same directory
|
|
149
|
+
with tempfile.NamedTemporaryFile(
|
|
150
|
+
mode=mode,
|
|
151
|
+
dir=file_path.parent,
|
|
152
|
+
prefix=f"{LOCK_CONFIG.TEMP_PREFIX}{file_path.name}",
|
|
153
|
+
delete=False
|
|
154
|
+
) as tmp_file:
|
|
155
|
+
tmp_path = tmp_file.name
|
|
156
|
+
try:
|
|
157
|
+
yield tmp_file
|
|
158
|
+
tmp_file.flush()
|
|
159
|
+
os.fsync(tmp_file.fileno())
|
|
160
|
+
except Exception:
|
|
161
|
+
# Clean up temp file on error
|
|
162
|
+
try:
|
|
163
|
+
os.unlink(tmp_path)
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
raise
|
|
167
|
+
|
|
168
|
+
# Atomically replace target file
|
|
169
|
+
try:
|
|
170
|
+
os.replace(tmp_path, str(file_path))
|
|
171
|
+
logger.debug(f"Atomically wrote to {file_path}")
|
|
172
|
+
except Exception as e:
|
|
173
|
+
try:
|
|
174
|
+
os.unlink(tmp_path)
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
raise FileLockError(f"Atomic write failed for {file_path}: {e}") from e
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def atomic_write_json(
|
|
181
|
+
file_path: Union[str, Path],
|
|
182
|
+
data: Dict[str, Any],
|
|
183
|
+
indent: int = LOCK_CONFIG.JSON_INDENT,
|
|
184
|
+
ensure_directory: bool = True
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Atomically write JSON data to file using temporary file + rename."""
|
|
187
|
+
file_path = Path(file_path)
|
|
188
|
+
|
|
189
|
+
if ensure_directory:
|
|
190
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
tmp_path = _write_to_temp_file(file_path, data, indent)
|
|
194
|
+
# Use os.replace() instead of os.rename() for atomic replacement on all platforms
|
|
195
|
+
# os.rename() fails on Windows if destination exists, os.replace() works on both Unix and Windows
|
|
196
|
+
os.replace(tmp_path, str(file_path))
|
|
197
|
+
logger.debug(f"Atomically wrote JSON to {file_path}")
|
|
198
|
+
except Exception as e:
|
|
199
|
+
raise FileLockError(f"Atomic JSON write failed for {file_path}: {e}") from e
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _write_to_temp_file(file_path: Path, data: Dict[str, Any], indent: int) -> str:
|
|
203
|
+
"""Write data to temporary file and return path."""
|
|
204
|
+
with tempfile.NamedTemporaryFile(
|
|
205
|
+
mode='w',
|
|
206
|
+
dir=file_path.parent,
|
|
207
|
+
prefix=f"{LOCK_CONFIG.TEMP_PREFIX}{file_path.name}",
|
|
208
|
+
suffix='.json',
|
|
209
|
+
delete=False
|
|
210
|
+
) as tmp_file:
|
|
211
|
+
json.dump(data, tmp_file, indent=indent)
|
|
212
|
+
tmp_file.flush()
|
|
213
|
+
os.fsync(tmp_file.fileno())
|
|
214
|
+
return tmp_file.name
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def atomic_update_json(
|
|
218
|
+
file_path: Union[str, Path],
|
|
219
|
+
update_func: Callable[[Optional[Dict[str, Any]]], Dict[str, Any]],
|
|
220
|
+
lock_timeout: float = LOCK_CONFIG.DEFAULT_TIMEOUT,
|
|
221
|
+
default_data: Optional[Dict[str, Any]] = None
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Atomically update JSON file using read-modify-write with file locking."""
|
|
224
|
+
file_path = Path(file_path)
|
|
225
|
+
lock_path = file_path.with_suffix(f'{file_path.suffix}{LOCK_CONFIG.LOCK_SUFFIX}')
|
|
226
|
+
|
|
227
|
+
with file_lock(lock_path, timeout=lock_timeout):
|
|
228
|
+
current_data = _read_json_or_default(file_path, default_data)
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
updated_data = update_func(current_data)
|
|
232
|
+
except Exception as e:
|
|
233
|
+
raise FileLockError(f"Update function failed for {file_path}: {e}") from e
|
|
234
|
+
|
|
235
|
+
atomic_write_json(file_path, updated_data)
|
|
236
|
+
logger.debug(f"Atomically updated JSON file: {file_path}")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _read_json_or_default(file_path: Path, default_data: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
240
|
+
"""Read JSON file or return default data if file doesn't exist or is invalid."""
|
|
241
|
+
if not file_path.exists():
|
|
242
|
+
return default_data
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
with open(file_path, 'r') as f:
|
|
246
|
+
return json.load(f)
|
|
247
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
248
|
+
logger.warning(f"Failed to read {file_path}, using default: {e}")
|
|
249
|
+
return default_data
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Storage backend metaclass registration system.
|
|
3
|
+
|
|
4
|
+
Backends are automatically discovered and registered when their classes are defined.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Dict
|
|
9
|
+
from .base import BackendBase, DataSink
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
_backend_instances: Dict[str, DataSink] = {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_storage_backends() -> Dict:
|
|
17
|
+
"""Get the storage backends registry, ensuring it's initialized."""
|
|
18
|
+
# Import backends to trigger registration
|
|
19
|
+
from . import memory, disk
|
|
20
|
+
try:
|
|
21
|
+
from . import zarr
|
|
22
|
+
except ImportError:
|
|
23
|
+
pass # Zarr not available
|
|
24
|
+
|
|
25
|
+
# Registry auto-created by AutoRegisterMeta on BackendBase
|
|
26
|
+
return BackendBase.__registry__
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Lazy access to registry
|
|
30
|
+
STORAGE_BACKENDS = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_backend_instance(backend_type: str) -> DataSink:
|
|
34
|
+
"""
|
|
35
|
+
Get backend instance by type with lazy instantiation.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
backend_type: Backend type identifier (e.g., 'disk', 'memory')
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Backend instance
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
KeyError: If backend type not registered
|
|
45
|
+
RuntimeError: If backend instantiation fails
|
|
46
|
+
"""
|
|
47
|
+
backend_type = backend_type.lower()
|
|
48
|
+
|
|
49
|
+
# Return cached instance if available
|
|
50
|
+
if backend_type in _backend_instances:
|
|
51
|
+
return _backend_instances[backend_type]
|
|
52
|
+
|
|
53
|
+
# Get backend class from registry
|
|
54
|
+
storage_backends = _get_storage_backends()
|
|
55
|
+
if backend_type not in storage_backends:
|
|
56
|
+
raise KeyError(f"Backend type '{backend_type}' not registered. "
|
|
57
|
+
f"Available backends: {list(storage_backends.keys())}")
|
|
58
|
+
|
|
59
|
+
backend_class = storage_backends[backend_type]
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
# Create and cache instance
|
|
63
|
+
instance = backend_class()
|
|
64
|
+
_backend_instances[backend_type] = instance
|
|
65
|
+
logger.debug(f"Created instance for backend '{backend_type}'")
|
|
66
|
+
return instance
|
|
67
|
+
except Exception as e:
|
|
68
|
+
raise RuntimeError(f"Failed to instantiate backend '{backend_type}': {e}") from e
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def create_storage_registry() -> Dict[str, DataSink]:
|
|
72
|
+
"""
|
|
73
|
+
Create storage registry with all registered backends.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Dictionary mapping backend types to instances
|
|
77
|
+
"""
|
|
78
|
+
# Get backends registry (triggers import and registration)
|
|
79
|
+
storage_backends = _get_storage_backends()
|
|
80
|
+
|
|
81
|
+
# Backends that require context-specific initialization (e.g., plate_root)
|
|
82
|
+
# These are registered lazily when needed, not at startup
|
|
83
|
+
SKIP_BACKENDS = {'virtual_workspace'}
|
|
84
|
+
|
|
85
|
+
registry = {}
|
|
86
|
+
for backend_type in storage_backends.keys():
|
|
87
|
+
# Skip backends that need context-specific initialization
|
|
88
|
+
if backend_type in SKIP_BACKENDS:
|
|
89
|
+
logger.debug(f"Skipping backend '{backend_type}' - requires context-specific initialization")
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
registry[backend_type] = get_backend_instance(backend_type)
|
|
94
|
+
except Exception as e:
|
|
95
|
+
logger.warning(f"Failed to create instance for backend '{backend_type}': {e}")
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
logger.info(f"Created storage registry with {len(registry)} backends: {list(registry.keys())}")
|
|
99
|
+
return registry
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cleanup_backend_connections() -> None:
|
|
103
|
+
"""
|
|
104
|
+
Clean up backend connections without affecting persistent resources.
|
|
105
|
+
|
|
106
|
+
For napari streaming backend, this cleans up ZeroMQ connections but
|
|
107
|
+
leaves the napari window open for future use.
|
|
108
|
+
"""
|
|
109
|
+
import os
|
|
110
|
+
|
|
111
|
+
# Check if we're running in test mode
|
|
112
|
+
is_test_mode = (
|
|
113
|
+
'pytest' in os.environ.get('_', '') or
|
|
114
|
+
'PYTEST_CURRENT_TEST' in os.environ or
|
|
115
|
+
any('pytest' in arg for arg in __import__('sys').argv)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
for backend_type, instance in _backend_instances.items():
|
|
119
|
+
# Use targeted cleanup for napari streaming to preserve window
|
|
120
|
+
if hasattr(instance, 'cleanup_connections'):
|
|
121
|
+
try:
|
|
122
|
+
instance.cleanup_connections()
|
|
123
|
+
logger.debug(f"Cleaned up connections for backend '{backend_type}'")
|
|
124
|
+
except Exception as e:
|
|
125
|
+
logger.warning(f"Failed to cleanup connections for backend '{backend_type}': {e}")
|
|
126
|
+
elif hasattr(instance, 'cleanup') and backend_type != 'napari_stream':
|
|
127
|
+
try:
|
|
128
|
+
instance.cleanup()
|
|
129
|
+
logger.debug(f"Cleaned up backend '{backend_type}'")
|
|
130
|
+
except Exception as e:
|
|
131
|
+
logger.warning(f"Failed to cleanup backend '{backend_type}': {e}")
|
|
132
|
+
|
|
133
|
+
# In test mode, also stop viewer processes to allow pytest to exit
|
|
134
|
+
if is_test_mode:
|
|
135
|
+
try:
|
|
136
|
+
from openhcs.runtime.napari_stream_visualizer import _cleanup_global_viewer
|
|
137
|
+
_cleanup_global_viewer()
|
|
138
|
+
logger.debug("Cleaned up napari viewer for test mode")
|
|
139
|
+
except ImportError:
|
|
140
|
+
pass # napari not available
|
|
141
|
+
except Exception as e:
|
|
142
|
+
logger.warning(f"Failed to cleanup napari viewer: {e}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class BackendRegistry(dict):
|
|
146
|
+
"""
|
|
147
|
+
Registry for storage backends.
|
|
148
|
+
|
|
149
|
+
This is a dictionary that automatically populates with available backends
|
|
150
|
+
when first accessed.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def __init__(self):
|
|
154
|
+
"""Initialize the backend registry."""
|
|
155
|
+
super().__init__()
|
|
156
|
+
# Populate with available backends
|
|
157
|
+
self.update(create_storage_registry())
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def cleanup_all_backends() -> None:
|
|
161
|
+
"""
|
|
162
|
+
Clean up all cached backend instances completely.
|
|
163
|
+
|
|
164
|
+
This is for full shutdown - clears instance cache and calls full cleanup.
|
|
165
|
+
Use cleanup_backend_connections() for test cleanup to preserve napari window.
|
|
166
|
+
"""
|
|
167
|
+
for backend_type, instance in _backend_instances.items():
|
|
168
|
+
if hasattr(instance, 'cleanup'):
|
|
169
|
+
try:
|
|
170
|
+
instance.cleanup()
|
|
171
|
+
logger.debug(f"Cleaned up backend '{backend_type}'")
|
|
172
|
+
except Exception as e:
|
|
173
|
+
logger.warning(f"Failed to cleanup backend '{backend_type}': {e}")
|
|
174
|
+
|
|
175
|
+
_backend_instances.clear()
|
|
176
|
+
logger.info("All backend instances cleaned up")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
|